Repository navigation
ci: deploy each release to staging before production - #179
Merged
Merged
Conversation
#171) Deleted before the PR opens.
- staging-gate.yml decides per tag: deploy, skip (STAGING_PINNED set, or check-serving-revisions exit 3 = app absent), or FAIL when it cannot tell -- an auth or Azure failure is never read as absence - check-serving-revisions.sh gains exit 3 (ABSENT) for az's exact ResourceNotFound on this app in this group - release.yml: gate -> deploy-staging -> production, production gated on the gate succeeding and staging doing what the gate decided - deploy.yml builds once and imports by digest; the production call passes staging's digest and skips the build, so both run one image - the deploy identity holds ContainerApp Reader at RG scope (granted 2026-09-29) so absence is observable; captured in infra/terraform - temporary self-test workflow, deleted before the PR opens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GEqmJd34vDn6rLCMHzZCpG
Its runs (36584594351, 36584985171) remain as the evidence.
- deploy.yml: require_image_digest makes production FAIL rather than rebuild if staging's digest does not arrive; image_digest on dispatch requires an explicit image_tag; target passed via env; the "cannot overwrite" claim scoped to one train run, with the manual-reuse hazard - staging-gate.yml: drop the container_app test hook (a wrong name reads as torn down); az config failure gets an ::error::; pin notice uses the reason's first line only and says how to catch staging up; cite the self-test runs, naming attempt 1 as the unpinned one - release.yml: recovery is `gh run rerun <id> --failed`, not a fresh run; per-job permissions comment covers three callers - CLAUDE.md: unpin does not restore staging; freeze route rebuilds and can overwrite staging's tag; skipped/cancelled table rows; "every tagged run" not "every night"; role described as read plus alert-rule write; image registry row; concurrency of a called workflow marked unverified; teardown deletes STAGING_PINNED - identity.tf names every non-container-app action the role adds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GEqmJd34vDn6rLCMHzZCpG
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The required-digest failure path can still rebuild and overwrite the GHCR release tag before failing.
Review effort: Balanced
Findings: 1
What changed in this PR
Updates the release train to deploy one image to staging before production, while safely handling pinned or absent staging.
Changes:
- Adds staging gating and digest-based deployment reuse.
- Distinguishes absent Container Apps from lookup failures.
- Captures the required Azure role and updates operational documentation.
| File | Description |
|---|---|
.github/workflows/staging-gate.yml |
Determines whether staging should deploy. |
.github/workflows/release.yml |
Sequences staging before production. |
.github/workflows/deploy.yml |
Adds digest-based build reuse. |
.github/scripts/check-serving-revisions.sh |
Adds explicit absent-app handling. |
.github/scripts/tests/check-serving-revisions.test.sh |
Tests absence detection. |
infra/terraform/identity.tf |
Captures the resource-group reader grant. |
infra/terraform/imports.tf |
Adds the role-assignment import. |
infra/terraform/README.md |
Documents deployment prerequisites. |
docs/runbooks/entra-cutover-staging-validation.md |
Clarifies the stale-image cutoff. |
CLAUDE.md |
Documents the new release process and recovery. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot: the build ran and pushed over the GHCR tag before the digest step failed. Plus a temporary self-test of that path, deleted before merge.
Its run (36706521420) remains as the evidence.
…ase-train # Conflicts: # .github/workflows/deploy.yml
Copilot: --failed may not re-run cancelled jobs, and GitHub's docs do not say. Record a recovery that depends only on deploy.yml, and why a full rerun is a no-op.
searledan
added a commit
that referenced
this pull request
Sep 30, 2026
…uild (#180) A time-boxed waiver for **CVE-2026-84782** (HIGH), which started failing the required `image-cve` check on 2026-09-30. It blocks every PR and `main`, and it was found on #179. ## The finding | | | |---|---| | Packages | `libssl3t64` and `openssl`, `3.0.13-0ubuntu3.15` (OpenSSL: information disclosure) | | Where | the runtime base image `mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled` | | Fixed in | `3.0.13-0ubuntu3.16`, which Ubuntu has shipped | | Why we can't fix it here | Microsoft has not rebuilt the image yet: the scan pulls the current tag and still finds `…3.15`. A chiselled image has no package manager, so the package can't be upgraded in our Dockerfile | Scan output from #179's `image-cve` job (run 36707891405): `Total: 2 (HIGH: 2, CRITICAL: 0)` — both this CVE, once in `libssl3t64` and once in `openssl`, so the one entry covers both. ## The waiver This is a `.trivyignore.yaml` entry in the format `docs/runbooks/vulnerability-scanning.md` requires: a statement, plus `expired_at: 2026-10-07`, which is **7 days**. If MCR hasn't published a rebuilt image by then, the finding re-blocks and gets reviewed again. If it has, the next scan passes without the waiver, and the entry can be removed at the next touch. Kept separate from #179 on purpose, so that a security exception is reviewable on its own rather than tucked into a CI change (decision: dsearle). **Verification:** this PR's own `image-cve` run is the test that Trivy honours the entry. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01GEqmJd34vDn6rLCMHzZCpG --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
It changes the production release path and its complete staging-to-production flow cannot be exercised until the first real post-merge train run.
Review effort: Balanced
Findings: None
Resolved since last review (2)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Closes #171
The nightly release train now deploys each tag to staging first, then production, from one build. Staging stays current instead of drifting; it was 22 days behind in #166.
The pre-production gate is a small side-effect, not the point. Staging's smoke test is the same set production's own deploy already runs and self-rolls-back on. It saves a few minutes of a broken production revision, and it catches no functional regression.
Shape
staging-gate.yml(new, reusable) decides per tag. It uses the staging OIDC credential withenvironment: { deployment: false }, so it records no phantom staging deployment. I verified that against the environment's deployment history.check-serving-revisions.shgains exit 3,ABSENT. It is returned only for az's exact(ResourceNotFound) The Resource 'Microsoft.App/containerApps/<app>' under resource group '<rg>' was not found, matched as a fixed string. A missing resource group, a missing role, a lapsed login, or another app's not-found all stayNOT ASSESSED(exit 2), and the gate fails on them.release.yml: production requires the gate to have succeeded and staging to have done what the gate decided:A staging job skipped for any other reason can't let production through.
deploy.ymlbuilds once and imports by digest. The import pullsghcr…@sha256:<digest>, never:tag. Withimage_digestset, the build is skipped.require_image_digest, so production fails rather than silently rebuilding if the digest doesn't arrive.vitally-mcp:v4.x.y), as decided.STAGING_PINNEDis an environment variable onstaging. While it is non-empty, staging is skipped and production still ships.::defanged, on every tagged run.AUTO_DEPLOYpattern.Azure change (already applied)
ContainerApp Readervitally-prod-rg-uksouthvitally-prod-id-uksouth(c57b35e7-…)fcffb5d1-5cdc-47e8-88f6-8578dd6d5139, 2026-09-29infra/terraform/identity.tf+ an import block inimports.tfWhy it was needed. Without the grant, absence could not be observed. Run 36581965911 checked this as the deploy identity before the grant:
revision liston a nonexistent app →AuthorizationFailed, the same answer as a lost role;containerapp list -g RG→ exit 0, listing only the apps the identity can see, which is fail-open.After the grant (run 36582385414):
(ResourceNotFound).What it adds. It adds read at resource-group scope, plus
Microsoft.Insights/alertRules/*(write on classic alert rules). The identity is also both apps' runtime identity, so the running containers get that read too.identity.tflists each action. The built-in role was chosen over a custom one (decision: dsearle).Verification
The real gate, called by a temporary self-test (since deleted).
deploy=truedeploy=false, torn-down noticeAADSTS700038)STAGING_PINNEDset for the run, then deleteddeploy=falseon all three, with the reason in the noticeAttempt 2 was a deliberately pinned re-run, so read attempt 1 for the unpinned cases. All three pre-review agents misread attempt 2 as the only evidence.
The self-test also caught a real bug in run 36584594351. Under Actions'
bash -e, exit 3 ended the step before thecasestatement saw it, so a torn-down staging would have failed the train instead of skipping. Fixed withrc=0; … || rc=$?.The production
if:, evaluated by GitHub over stub jobs (in both self-test runs):deploy.yml, by staging-only manual dispatches of this branch (agreed beforehand):sha-dedad9e=sha256:7910a7ef…b015. GHCR's package version for that tag has the same name. Smoke and metadata checks passedimage_digestgiven, build skippedgiven by the caller (build skipped)is in the summary. Smoke passedAfter both, the spin-up check on revision
--0000018gaveReadOnly=true, App Insightsset,PASS.Other checks
main's script and pass against this branch.Not yet proven — needs the real train, agreed with you per run
STAGING_PINNEDset during a train run: staging is skipped with the reason, and production deploys. Needs a further new tag.Known edges, documented rather than fixed
deploy.yml's top-levelconcurrencyapplies when it is called. If it does, a queued manual staging dispatch can cancel the train's pending staging call. Production then stops (safe), and recovery isgh run rerun <id> --failed.release.ymlandCLAUDE.md: re-run the failed jobs, not a fresh train, which finds no new tag and does nothing.image_digeststill rebuilds over it. The freeze route inCLAUDE.mdnow says to pass the digest.CONTAINER_APPon the staging environment would read as torn down. The notice names the app it looked for.Docs
The drift was swept by subject across
CLAUDE.md,infra/terraform/README.md, the runbooks and the workflow comments.CLAUDE.mdchanges:STAGING_PINNED.🤖 Generated with Claude Code
https://claude.ai/code/session_01GEqmJd34vDn6rLCMHzZCpG