agilentics / boiler
name: Deploy to App Engine

# Authenticate, run the tests, migrate through the Cloud SQL Auth Proxy, verify
# the schema matches the models, then deploy. Non-secret env already lives in
# app.yaml, so CI only renders the true secrets into the gitignored
# env_secrets.yaml that app.yaml `includes:`.
#
# Production ships on a TAG, not on a push to main. Main goes to integration
# (deploy-int.yml) the moment it is merged; a `v*` tag is what promotes an
# integration-tested commit from there to here. One event for both - merge,
# deploy, release - leaves nothing between a reviewer's merge and a user, and
# makes "test on int first" impossible. Splitting them costs no speed: main still
# deploys automatically, just to int.
#
# workflow_dispatch stays as the hotfix path: GitHub lets a dispatch choose its
# own ref, so an operator can ship any commit here without inventing a tag for it.
on:
  push:
    tags: ['v*']
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest

    env:
      CLOUD_SQL_INSTANCE: myapp-000000:us-central1:myapp-postgres
      # Committed in app.yaml already, so not secrets. Named here so the
      # migration steps below cannot drift from what the service will read.
      DB_USER: myapp
      DB_NAME: myapp

    steps:
      - uses: actions/checkout@v4
        with:
          # Full history: the ancestry check below needs to see main.
          fetch-depth: 0

      # A tag is a promotion of something that was already on integration, so it
      # must name a commit that is on main. Without this, `git tag v9 && git push
      # --tags` from any branch - or from a stale local main - ships code that no
      # int deploy ever ran, through the one door that is supposed to guarantee
      # the opposite. Dispatch skips it: choosing an arbitrary ref by hand is
      # exactly what the hotfix path is for.
      - name: Tag must be an ancestor of main
        if: github.event_name == 'push'
        run: |
          git fetch --no-tags origin main
          if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
            echo "::error::${GITHUB_REF_NAME} points at $GITHUB_SHA, which is not on main." >&2
            echo "Production only ships commits that reached main and therefore integration." >&2
            exit 1
          fi
          echo "$GITHUB_REF_NAME ($GITHUB_SHA) is on main."

      - uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_CREDENTIALS }}

      - name: Set up Python 3.10
        uses: actions/setup-python@v5
        with:
          python-version: '3.10'
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      # The suite runs against in-memory SQLite, so it needs no database and can
      # run before the proxy is up. e2e is deselected rather than skipped:
      # Playwright is not in requirements here.
      - name: Tests
        run: pytest -q -m "not e2e"

      - name: Download Cloud SQL Auth Proxy
        run: |
          curl -fsSL -o cloud-sql-proxy \
            https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.11.4/cloud-sql-proxy.linux.amd64
          chmod +x cloud-sql-proxy

      # --health-check exposes /readiness on :9090; the proxy reports ready only
      # once it can actually reach the instance. Polling that beats a fixed sleep
      # (which races) and surfaces a dead proxy here rather than as a confusing
      # connection error in the migration step below.
      - name: Start Cloud SQL Auth Proxy
        run: ./cloud-sql-proxy --port 5432 --health-check "$CLOUD_SQL_INSTANCE" &

      - name: Wait for proxy to be ready
        run: |
          for i in $(seq 1 30); do
            if curl -fsS http://localhost:9090/readiness >/dev/null 2>&1; then
              echo "Proxy ready after ${i}s"
              exit 0
            fi
            sleep 1
          done
          echo "Cloud SQL Auth Proxy did not become ready in 30s" >&2
          exit 1

      # INSTANCE_UNIX_SOCKET is intentionally unset, so db_wrapper builds the TCP
      # URL and connects to the proxy on localhost:5432.
      - name: Run database migrations
        run: alembic upgrade head
        env:
          DB_USER: ${{ env.DB_USER }}
          DB_PASS: ${{ secrets.DB_PASS }}
          DB_NAME: ${{ env.DB_NAME }}
          DB_PORT: 5432

      # Nothing else compares the models to the schema: the tests need no
      # database, `alembic upgrade head` only applies what exists, and the deploy
      # does not ask - so a model with a column nobody wrote a migration for
      # sails through all three and fails at runtime. Placed after the migration
      # and before the deploy, so a mismatch stops the release rather than a user
      # finding it.
      - name: Check the schema matches the models
        run: alembic check
        env:
          DB_USER: ${{ env.DB_USER }}
          DB_PASS: ${{ secrets.DB_PASS }}
          DB_NAME: ${{ env.DB_NAME }}
          DB_PORT: 5432

      # No secret is committed to app.yaml. app.yaml pulls this file in via
      # `includes:`; it exists only in the runner's workspace. An unset GitHub
      # secret renders as an empty string, which settings.py treats as "feature
      # off / derive from request", so a line is safe to keep before its secret
      # exists.
      #
      # Each secret reaches the script as an environment variable and is written
      # out by a QUOTED heredoc, so nothing interpolates a secret's *text* into
      # something that parses it. Both halves matter, and neither is theoretical:
      # an earlier version pasted the values into an unquoted heredoc, where the
      # shell expanded them - an SMTP password containing a "$" arrived three
      # characters short, and every mail the app tried to send was refused for
      # three days while the sign-up page still said "check your email". json
      # then quotes each value the way YAML reads it back, so a secret holding a
      # quote or a backslash cannot break the file either.
      - name: Render secret env file
        env:
          DB_PASS: ${{ secrets.DB_PASS }}
          SECRET_KEY: ${{ secrets.SECRET_KEY }}
          SMTP_PASS: ${{ secrets.SMTP_PASS }}
          GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
          GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
        run: |
          python - <<'PY'
          import json, os

          KEYS = ["DB_PASS", "SECRET_KEY", "SMTP_PASS",
                  "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"]

          with open("env_secrets.yaml", "w", encoding="utf-8") as f:
              f.write("env_variables:\n")
              for key in KEYS:
                  f.write("  %s: %s\n" % (key, json.dumps(os.environ.get(key, ""))))

          # Lengths only - enough to catch a truncated secret in the log without
          # putting one there.
          print("rendered:", {k: len(os.environ.get(k, "")) for k in KEYS})
          PY

      - uses: google-github-actions/deploy-appengine@v2
        with:
          project_id: myapp-000000
          deliverables: app.yaml