Skip to content

Deploy to Azure, behind a green CI run and a human - #35

Merged
Badri-Rajendran merged 1 commit into
feature_container_imagefrom
feature_azure_cd
Sep 23, 2026
Merged

Badri-Rajendran merged 1 commit into
feature_container_imagefrom
feature_azure_cd

Conversation

@Badri-Rajendran

Copy link
Copy Markdown
Owner

Problem

CLAUDE.md requires: "CD: push images to Azure Container Registry, run Alembic migrations, deploy to Azure" and "Gate production deploys on green CI plus manual approval; keep rollback a single re-run."

docs/decisions/0002-ci-scope.md:37-41 deferred it: "Requires an Azure subscription, service principal, and GitHub Environment secrets that don't exist in this project yet." A subscription now exists. This is the last of that ADR's three deferrals.

Implementation

Topology (ADR 0023): Container Apps for the API, Static Web Apps for the Vite build, PostgreSQL Flexible Server with pgvector.

The choices that are not obvious:

  • Migrations run as a Container Apps job, on the digest being deployed. Running Alembic from the GitHub runner would mean opening the database to GitHub's IP ranges for the sake of a deploy step. Running it inside the environment keeps the server on --public-access None, and guarantees the schema is migrated by exactly the code that will then use it.
  • Tagged by commit, deployed by digest. A tag can be moved; a digest cannot. What the reviewer approves is what runs.
  • workflow_run on a successful CI run is the trigger, so "green CI" is a precondition, not a convention — a push that fails CI never reaches CD.
  • environment: production with a required reviewer is the manual approval. Nothing reaches Azure until a person says so.
  • A post-deploy check polls /api/counties?zip= until 200, so a revision that starts but cannot serve fails the run rather than sitting there.
  • OIDC federated credentials — no Azure client secret is stored in GitHub at all. id-token: write at the workflow level, contents: read otherwise.
  • One replica, deliberately. Flask-Limiter keeps counters in process, so a second doubles the 5/min register and 10/min login limits. The runbook marks --max-replicas 1 as load-bearing.

The PDFs stay put. ADR 0016 and ADR 0019 make ingestion a local operation; nothing in this workflow uploads data/. The database is seeded once by hand from a 75 MB pg_dump — the text, the chunks, the catalog and the BM25 row (ADR 0021), not the documents.

Rollback is az containerapp revision activate <previous> — one command, which is what ADR 0002 asked for.

Validation

  • Both workflow files parse (yaml.safe_load).
  • uv run pytest -q — 415 passing; ruff check clean. No application code changed.
  • Security review of the workflow itself: inputs.sha on workflow_dispatch is typed by a human and flows into ref: and an image tag, so it is checked against ^[0-9a-f]{40}$ first — a ref-injection vector otherwise. Every az argument comes from repository variables through env:, never interpolated into a shell line.
  • docs/runbooks/deploy.md covers one-time setup, the per-merge path, rollback, a failed migration, a 500 after deploy, and cold starts.
  • ADR 0002's three deferrals are now struck through in place, with its status line updated.

Not verified against a live subscription — I have no credentials, and would not create cloud infrastructure unprompted. The workflow is written to the documented APIs; the first run is the real test, and the runbook is written for someone doing it.

Security / DB / API impact

  • Security: no stored Azure credential (OIDC); database not publicly reachable; secrets via secretref: on the Container App, never in the repo; image deployed by immutable digest; deploy gated on a human.
  • DB: no schema change here. Migrations are run by this workflow, not added.
  • API: none.

Still missing, named rather than discovered later

No staging environment, no alerting, and no shared rate-limit store — which is what pins the app at one replica. All three are in ADR 0023's consequences.

Note

Top of the stack: #34 → #33 → #32 → #31 → #30 → #29 → #28.

Copilot AI lite review requested due to automatic review settings September 22, 2026 20:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Closes the second of ADR 0002's two deferred items, which waited on a
subscription that now exists.

Container Apps for the API, Static Web Apps for the browser app, and a
PostgreSQL Flexible Server. The choices that are not obvious:

Migrations run as a Container Apps job on the same image digest that is about
to serve traffic. Running Alembic from the GitHub runner instead would mean
opening the database to GitHub's IP ranges for the sake of a deploy step; this
keeps the server private and guarantees the schema is migrated by exactly the
code that will then use it.

The image is tagged with the commit and deployed by digest. A tag can be
moved, a digest cannot, so what the reviewer approves is what runs. The
trigger is `workflow_run` on a successful CI run, which makes green CI a
precondition rather than a convention, and `environment: production` with a
required reviewer is the approval. A post-deploy check polls the API until it
answers 200, so a revision that starts but cannot serve fails the run.

Sign-in is OIDC federated credentials, so no Azure secret is stored in GitHub
at all. The commit a manual run asks for is checked against a 40-hex pattern
before it reaches `ref:` or an image tag.

One replica, deliberately: Flask-Limiter keeps its counters in process, so a
second would double the 5/min on register and 10/min on login. Raising it and
adding a shared store are the same piece of work.

The database is seeded once by hand from a 75 MB dump, not by CI. Ingestion
stays on the machine that holds data/ (ADR 0016, 0019), and nothing here
uploads a PDF.

docs/runbooks/deploy.md covers the one-time setup, the deploy path, rollback,
and the failures worth expecting. What is still missing — staging, alerting,
a shared rate-limit store — is named rather than left to be discovered.
@Badri-Rajendran
Badri-Rajendran merged commit fd9657a into feature_container_image Sep 23, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants