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