Repository navigation
fix(ci/azure): serialize Terraform state writers on environment, not branch - #1803
Conversation
…branch
The Azure Terraform state blob is keyed on environment
(github-<env>.terraform.tfstate), but deploy-azure.yml serialized on
branch (deploy-azure-${{ github.ref }}). prepare maps every non-dispatch
event to dev, so runs on different refs landed in different concurrency
groups, ran simultaneously, and applied against one state file.
That collision would have been loud on its own, because the Azure blob
lease makes the second writer fail with "Error acquiring the state lock".
Three az storage blob lease break invocations removed that safety net:
none checked that a lease was held, none checked its age, and none passed
--lease-break-period, so the default broke an active lease immediately.
The second run broke the first's live lease, both wrote, and the later
writer's stale read dropped the earlier one's entries. Two role
assignments ended up live in Azure but absent from state, 409ing every
subsequent apply until imported by hand.
Move the group to the build-and-deploy job, where needs is in scope
(workflow-level concurrency sees only github, inputs and vars), and key
it on the same value that builds the state key:
group: azure-tfstate-${{ needs.prepare.outputs.environment }}
Group and state file therefore cannot drift apart. Apply the same group
to the other two writers of that blob, cleanup-staging.yml's
destroy-azure and rollback.yml's rollback-azure, so serialization holds
across workflows rather than within one. deploy-all.yml reaches the state
only through deploy-azure.yml, whose job is already serialized, and gets
no group of its own: the same group on the caller job would deadlock
against the inner job queued behind it.
Delete all three lease breaks. The two pre-emptive ones are the defect;
each survives as a renamed step that still writes the backend config
terraform init consumes. The failure-path one is deleted rather than made
conditional because it was a no-op exactly when it was harmless: on
failure Terraform has already unlocked on its way out, on cancellation a
SIGINT'd apply may still be writing state and the step broke the lease
out from under it, and a hard runner death runs no steps at all.
cancel-in-progress stays false and is now stated explicitly, since
cancelling mid-apply is how you get a half-applied stack and a lease
nobody releases.
Closes #1801
📝 WalkthroughWalkthroughAzure workflows now serialize Terraform operations by environment state. Automatic Azure Blob lease breaking was removed. The deployment caller documents its deliberate lack of duplicate concurrency control. ChangesAzure Terraform state locking
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Review of #1803 found the deploy-all.yml comment asserted an equality that does not hold: the group on the called workflow's build-and-deploy job is derived from that workflow's own prepare output, which is not necessarily the environment deploy-all computed. Reword, and note that GitHub documents the sibling hazard for cancel-in-progress: true. Record two consequences of sharing the group across workflows on rollback-azure: GitHub keeps one pending entry per group so a queued rollback can be evicted by a later arrival (the summary job exits 1 on any non-success, so it is not silent), and it is undocumented whether a job parked awaiting environment approval holds its group.
Independent adversarial review — head
|
…config Review round 2 on #1803. The comment added in c09fe58 asserted that no environment carries required reviewers today. cleanup-staging.yml deliberately refuses to restate that same fact, precisely so the comment cannot rot into false reassurance, and rollback.yml's own header instructs the reader to configure those reviewers. Point at #1660 instead; the conditional framing already carries the meaning without asserting current config. Also correct the deploy-all.yml note: GitHub's documented behaviour when caller and called share a group at cancel-in-progress: true is that the already-running caller is cancelled, not that the two cancel each other.
Adversarial review of #1803 showed the removal rationale claimed more than is true. A failed apply does not always unlock: the unlock call itself can fail ("Error releasing the state lock", e.g. OIDC expiry mid-apply), Terraform can crash, and Actions SIGKILLs after the cancellation grace period rather than letting the graceful shutdown finish. The azurerm backend takes an infinite lease, so none of those self-expire. State the residue instead of denying it. The decisive argument is unchanged and does not depend on the overstatement: a step that clears those cases can only do so by also breaking live leases, which is the defect this issue is about. Recovery stays deliberate and manual.
|
@coderabbitai review The automatic review on this PR hit the adaptive rate limit before it ran, so no review has landed yet. Requesting it now that the window has reopened. Head is |
|
🧠 Learnings used
|
|
@coderabbitai full review The automatic review on this PR hit the adaptive rate limit and never ran, so no review has landed. Requesting a full review rather than incremental, since an incremental review after a throttle can skip the commits that were in flight. Head is Worth your attention: the change moves Terraform state serialization from a branch-keyed workflow-level |
|
This uses the repository guidance for rate-limited reviews: a full review includes commits that an incremental review can omit. 🧠 Learnings used✅ Action performedFull review finished. |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/deploy-all.yml:
- Around line 166-170: Update the concurrency explanation comment in the
workflow_call section to state that inputs.environment is passed through
directly to the called workflow and matches the caller’s computed environment;
remove the claim that the called workflow’s prepare output may differ from
environment.
🪄 Autofix
❌ Autofix failed (check again to retry)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6be1c247-7c26-42da-b0d6-d568ae4c06e6
📒 Files selected for processing (4)
.github/workflows/cleanup-staging.yml.github/workflows/deploy-all.yml.github/workflows/deploy-azure.yml.github/workflows/rollback.yml
|
Autofix skipped. No unresolved CodeRabbit review comments with fix instructions found. |
…, not branch (#1812) Both halves of #1801 were fixed for Azure only in #1803 and were still live on AWS and GCP. Half 1: the state key is built from the environment but the concurrency group was keyed on `github.ref`, so two runs on different refs that resolve to the same environment landed in different groups and applied against one state file. Workflow-level `concurrency` cannot see `needs`, so each group moves to the job that writes state, keyed on the same value that builds the state key: aws-tfstate-<env> github-<env>/terraform.tfstate (S3) aws-fargate-tfstate-<env> github-fargate-<env>/terraform.tfstate (S3) gcp-tfstate-<env> github-<env>/default.tfstate (GCS) Applied to all ten previously ungrouped state-mutating jobs across deploy-aws-lambda.yml, deploy-aws-fargate.yml, deploy-gcp.yml, destroy-fargate-dev.yml, cleanup-staging.yml and rollback.yml, so serialization holds across workflows, not just within one. `cancel-in-progress: false` on every one: cancelling mid-apply leaves a half-applied stack and a stuck lock. Half 2: four steps deleted the state lock object with no age check and no check that the lock was this run's. Two ran unconditionally before `terraform init`, two on `failure() || cancelled()`. The `cancelled()` half is the decisive one: those steps run while `terraform apply` is still shutting down, destroying a lock the dying run may still be using. All four are removed rather than made conditional, so a real collision fails loudly with "Error acquiring the state lock". deploy-aws-fargate.yml's operator-gated `clear_stale_lock` step is kept as the recovery path. destroy-fargate-dev.yml was not named in the issue but writes github-fargate-dev/terraform.tfstate and carried both defects. rollback.yml's rollback-aws-fargate takes the aws-tfstate-* group because its backend key is the Lambda namespace, not the Fargate one. That pre-existing mismatch is tracked in #1811. Closes #1806
Closes #1801
What was broken
The Azure Terraform state blob is keyed on environment (
github-<env>.terraform.tfstate), butdeploy-azure.ymlserialized on branch (concurrency.group: deploy-azure-${{ github.ref }}).preparemaps every non-dispatch event todev, so two runs on different refs landed in different concurrency groups, ran simultaneously, and applied against one state file.On its own that collision would have been loud: the Azure blob lease makes the second writer fail with
Error acquiring the state lock. Threeaz storage blob lease breakinvocations removed that safety net. None of them checked that a lease was held, none checked its age, and none passed--lease-break-period, so the default broke an active lease immediately. The step named "Break stale state lock" never established staleness. That is what turned a noisy collision into a silent one: the second run broke the first's live lease, both wrote, and the later writer's stale read dropped the earlier one's entries.The fix
1. Serialize on the value that identifies the state file.
The trap here is that workflow-level
concurrencycannot seeneeds; it is limited togithub,inputsandvars. Rather than re-deriving the environment at that level (which would duplicateprepare's mapping and, on theworkflow_callpath fromdeploy-all.yml, risk not applying at all), the group moves to the job level, whereneedsis in scope:This is the exact value the next step interpolates into the state key, so the group and the state file cannot drift apart. Workflow-level
concurrencyis removed; it was fully subsumed (same ref implies same environment implies same group) and keeping it would have implied a state guard it never provided.The same group is applied to the other two writers of that blob, so serialization holds across workflows, not just within one:
deploy-azure.ymlbuild-and-deploygithub-<env>.terraform.tfstateazure-tfstate-<env>cleanup-staging.ymldestroy-azuregithub-staging.terraform.tfstateazure-tfstate-stagingrollback.ymlrollback-azuregithub-<env>.terraform.tfstateazure-tfstate-<env>deploy-all.ymlis the fourth workflow named in the issue and gets no group, only a comment saying so. It reaches the state exclusively throughuses: ./.github/workflows/deploy-azure.yml, whosebuild-and-deployjob runs as a real job of that run and is serialized there. Putting the same group on the caller job would deadlock: the caller would hold the group while waiting on the inner job queued behind it.cancel-in-progress: falseis stated explicitly on all three rather than left to the default. Cancelling mid-terraform applyis how you get a half-applied stack and a lease nobody releases.2. Delete the lease breaks, all three.
The two pre-emptive ones (
deploy-azure.yml,cleanup-staging.yml) are the defect itself. Each step also generated/tmp/backend.tfbackend, whichterraform initconsumes, so the step survives as a renamed backend-config writer with the lease break removed.The third,
Release state lock on failure(if: failure() || cancelled()), is deleted rather than made conditional. The decisive argument is thecancelled()half: GitHub SIGINTs the step and then runs theif: cancelled()steps whileterraform applyis still gracefully shutting down and may still be writing state, so the step broke a live lease out from under an active writer. That is a corruption vector, not a cleanup.An earlier draft of this section claimed the
failure()half was simply a no-op because Terraform always unlocks on its way out. Adversarial review showed that overstates it, and the code comment has been corrected. A lease is genuinely stranded when:Error releasing the state lock, e.g. OIDC token expiry mid-apply, a 403 on the storage account), and those conditions correlate with whatever failed the apply, so this is not exotic;The azurerm backend takes an infinite blob lease, so none of these self-expire. That residue is accepted rather than denied: recovery is
terraform force-unlock <ID>with the ID Terraform prints. The reason to delete anyway is that a step which clears those cases can only do so by also breaking live leases, which is the defect this issue is about. A loudError acquiring the state lockis the correct outcome of a real collision and is now allowed to happen.How this would have prevented the June 9 incident
Per #1801, at the creation instant of
cost_management_reader(2026-06-09T15:42:51Z):feat/multicloud-web-frontenddeploy-azure-refs/heads/feat/multicloud-web-frontendazure-tfstate-devmaindeploy-azure-refs/heads/mainazure-tfstate-devBoth runs are non-dispatch events, so
preparereturneddevfor both and both wrotegithub-dev.terraform.tfstate. Under the old group they were in different groups and both ran. Under the new group they are in the same group: run 27217600951'sbuild-and-deploywould have sat pending at 15:38:01 until 27217251153 finished at 15:58:15, and would then haveterraform init'd against the state that run had just written. There is no window in which both hold the blob, so no read-modify-write of a stale state, and no dropped role assignments.Both runs were verified as
event: pushviagh run view, so both tookprepare's*)branch. Note the feature-branch run was possible becausedeploy-azure.ymlreached pushes beyondmainat the time; today'son.push.branches: [main]narrows that particular path. It does not close the hole: aworkflow_dispatchcan still be launched from any ref, anddeploy-all.yml,rollback.ymlandcleanup-staging.ymlall reach the same blob independently of the push trigger. The trigger list is not the guard; the concurrency group is.Belt and braces: even if the groups were somehow bypassed, deleting the lease breaks restores the blob lease as a real barrier, so the second writer fails loudly instead of proceeding.
Verification
actionlint deploy-azure.yml deploy-all.yml cleanup-staging.yml rollback.ymlorigin/mainafter normalizing file:line prefixes. Every finding is a pre-existing shellcheckSC2086/SC2129info/style note on steps this PR does not touch. Zero new findings. actionlint is not wired into CI or pre-commit, so this is not a gate either way.needs.…added to a workflow-levelconcurrencyin a scratch copycontext "needs" is not allowed here. available contexts are "github", "inputs", "vars"The negative control matters: it proves actionlint actually enforces context availability for
concurrency, so the clean run on the job-level form is evidence the expression resolves, not evidence that nothing was checked. It also confirms the docs' scoping rule first-hand rather than on trust.prepare's environment derivation was re-executed standalone for every trigger path to confirm the group can never be partially empty:The rows are labelled by the
EVENT_NAMEvalue fed toprepare, since that is what its script branches on. Note per #1805 that a realworkflow_callnever presentsworkflow_callasgithub.event_name; that does not change the empty-suffix conclusion, because every arm of the first case statement is still filtered by the allowlist below.The load-bearing property: can the group ever be
azure-tfstate-with an empty suffix?No, and importantly the
*) ENVIRONMENT=dev ;;catch-all is not what guarantees it. That arm only covers non-dispatch events. Theworkflow_dispatch|workflow_callarm assignsENVIRONMENT="$INPUT_ENVIRONMENT", whichprepareexplicitly defaults to the empty string (INPUT_ENVIRONMENT="${INPUT_ENVIRONMENT:-}") and which is typed as a free-form string on theworkflow_callpath. So an empty value is reachable inside the step.What makes it safe is the second case statement, the allowlist:
Empty string does not match
dev|staging|prod, so it falls to*)and exits 1 underset -euo pipefail.preparethen fails, andbuild-and-deploydeclaresneeds: preparewith noif:(verified by reading the job's direct children:name,runs-on,needs,concurrency,outputs,env,steps, and noif). A job whoseneedsfailed or was skipped is skipped, so it never dispatches and never occupies a group. Job-levelconcurrencyis evaluated at dispatch, afterneedsresolves, so there is no ordering in which an empty value claims a group.Verified empirically, not inferred: the
workflow_call input=''andinput='bogus'rows in the table above are actual executions ofprepare's script, and both exit non-zero.The other load-bearing property: do the three writers share one vocabulary?
They only serialize against each other if they produce the identical string for the same environment. A
developmenthere against adevthere would put a rollback and a deploy in different groups against one blob, which is the original bug on the worst possible path.deploy-azure.ymlbuild-and-deployneeds.prepare.outputs.environmentdev,staging,prodonly, enforced byprepare's runtime allowlist (it needs one, becauseworkflow_callinputs are free-form strings)rollback.ymlrollback-azureinputs.environmentdev,staging,prodonly, enforced bytype: choice, options: [dev, staging, prod]atrollback.yml:34-38.rollback.ymlhas only aworkflow_dispatchtrigger (noworkflow_call, norelease), so there is no free-form path into itcleanup-staging.ymldestroy-azurestagingSame three lowercase tokens, no divergence reachable. (GitHub also documents concurrency group names as case insensitive, so a future case slip would not split a group either.)
And each group is keyed on the same value its own state key is built from, which is what stops this fix reproducing the original defect somewhere new:
deploy-azure.yml:124needs.prepare.outputs.environment:161ENVIRONMENT: needs.prepare.outputs.environment->:163github-%s.terraform.tfstaterollback.yml:418inputs.environment:473ENVIRONMENT: inputs.environment->:477github-%s.terraform.tfstatecleanup-staging.yml:260literalstaging:303literalgithub-staging.terraform.tfstateAll three resolve to the same blob name shape
github-<env>.terraform.tfstate. (rollback.yml:234and:322usegithub-%s/terraform.tfstatewith a slash, but those are the AWS jobs on a different backend, correctly outside this group.)Repo-wide check for other writers:
rgacross.github/workflows/,scripts/andMakefilefinds no other CI path that runsterraform apply/destroyagainstterraform/environments/azure.database-migration.yml'smigrate-azurejob runs onlyterraform output, which takes no lock, so it needs no group.What remains unproven
cancel-in-progress: falseprotects the running job, not the pending one. Arollback-azurequeued behind an in-flight prod deploy is cancelled if another deploy queues into the same group. A rollback evaporating mid-incident is a real cost of sharing one group across three workflows, and it is inherent to that sharing. It is not silent:rollback.yml's summary job exits 1 on any non-success result, so an evicted rollback reddens the run.cleanup-staging.ymlhas no such aggregator, so an evicteddestroy-azureshows only as a cancelled job. Documented in the job comments.environment:bindings are a hazard, not just an unknown. Beyond the open question of whether an approval-parked job holds its group, if it does, the mitigation with the right shape is the one already used fordeploy-all.yml: keep the group off the approval-gated job and put it on an inner job past the gate. Not done here because no environment currently carries protection rules (#1660) and the restructure is larger than this fix warrants.mainwill exercise the deploy path; nothing will exercise thecleanup-staging.yml/rollback.ymlgroups until one of those workflows is dispatched.azure-tfstate-literals are matched by convention. A future writer of that blob added without the group, or a typo in the prefix, would silently not serialize, the same class of silent failure as the original bug. Filed as a follow-up so it does not evaporate.environment:bindings is untested.rollback-azureanddestroy-azureare bound to deployment environments. If required-reviewer rules are ever configured on those (per sec(ci): deployment environments have no protection rules, so environment-bound credentialed jobs run unapproved cloud-commitments-platform#141 none exist today), it is unclear to me whether a job parked awaiting approval holds its concurrency group. If it does, an unapproved rollback would block deploys to that environment until someone acts on it. I could not settle this from the docs and did not test it. Blocking is still the safer failure than concurrent writers, so it does not change the design, but it is a real unknown.force-unlock. That is the intended trade per the issue, but it is a real operational change, not a free win.Other providers: BOTH halves of this bug are live on AWS and GCP
An earlier draft of this section claimed the silent-loss half did not transfer to AWS because S3 uses a lock file rather than a blob lease. That was wrong, and adversarial review caught it. Corrected:
Half 1, branch-keyed groups against environment-keyed state:
deploy-aws-lambda.yml:33-35->deploy-lambda-${{ github.ref }}deploy-aws-fargate.yml:28-30->deploy-fargate-${{ github.ref }}deploy-gcp.yml:27-29->deploy-gcp-${{ github.ref }}Half 2, unconditional destruction of another writer's live lock,
if: failure() || cancelled(), no age check, no held-lock check:deploy-gcp.yml:156-167->gsutil rm "gs://${BUCKET}/github-${ENVIRONMENT}/default.tflock"deploy-aws-lambda.yml:269-280->aws s3 rm "s3://${BUCKET}/github-${ENVIRONMENT}/terraform.tfstate.tflock"Deleting another run's live lock object is exactly as destructive as breaking its lease. The lock mechanism differs; the failure mode does not. So the same silent state loss is reachable on GCP and AWS Lambda today.
Per the scope given for this change, that is flagged, not fixed. Widening it is a separate decision, and the AWS/GCP fix is not a copy-paste of this one (different backends, different lock semantics,
deploy-aws-lambda.ymlderives its environment differently). Filed as #1806.Follow-ups filed
azure-tfstate-*group across the three writers; a fourth writer added without it silently does not serialize.github.event_nameis the caller's event, sodeploy-azure.yml'sworkflow_callcase arm is dead code and a release-triggereddeploy-all.ymlsilently deploys to dev while reporting prod. Affectsdeploy-azure.yml,deploy-gcp.ymlanddeploy-aws-fargate.yml;deploy-aws-lambda.yml:110-118already handles it and is the reference implementation. Does not affect this PR: the group and the state key both read the sameneeds.prepare.outputs.environment, so whateverpreparedecides, the group names the blob that actually gets written. It is a wrong-environment bug, not a serialization bug.Two pre-existing defects also surfaced during review and are not filed, since they are adjacent and unverified by me end to end:
rollback.yml:49pinsTF_VERSION: 1.6.0against a stack requiring>= 1.10.0(sorollback-azuremay not init at all today), anddatabase-migration.yml:431runs a bareterraform initwith no-backend-configagainst a partial backend block. Flagging rather than filing so someone with more context decides.Summary by CodeRabbit