Repository navigation
fix(ci): no concurrency group on rollback, database-migration, cleanup-staging or destroy-fargate-dev #1593
Description
Reviewed commit: be11bdcb5. Note: origin/main moved to 3e9660d06 during the review; re-verify against current main before changing code, since a finding may have been fixed or moved.
Where
.github/workflows/rollback.yml- noconcurrency:key; writes state keygithub-<env>/terraform.tfstateat:230.github/workflows/database-migration.yml- noconcurrency:key.github/workflows/cleanup-staging.yml- noconcurrency:key.github/workflows/destroy-fargate-dev.yml- noconcurrency:key- Contrast, all four deploy workflows declare one:
deploy-aws-lambda.yml:29-31,deploy-aws-fargate.yml:28-30,deploy-azure.yml:29,deploy-gcp.yml:27 deploy-aws-lambda.yml:150writes the identical state keygithub-<environment>/terraform.tfstate
What
Four workflows that mutate the same Terraform state keys and the same production databases can run in unlimited parallel with each other, and in parallel with the deploy workflows. The deploy workflows' own concurrency groups do not cover them, and those groups are keyed on github.ref while the state key is ref-independent, so even between two deploy runs the group does not correspond to the resource being serialised.
Failure scenario
A rollback and a push-triggered deploy overlap. Both target github-<env>/terraform.tfstate. The deploy loses the lock race and fails; its if: failure() || cancelled() handler at deploy-aws-lambda.yml:190 then deletes the lock that the rollback is holding mid-apply, and a third run is free to acquire it and apply concurrently. The account in question holds the purchase-executing Lambda, its RDS instance and its secrets, so a split-brain state there is a production money-path outage, not a cosmetic one.
For database-migration.yml the equivalent is two concurrent migrate up runs against one production database, which produces the golang-migrate dirty state that requires manual CUDLY_FORCE_MIGRATION_VERSION recovery.
Fix direction
Add concurrency: {group: <state-key-or-db-identity>, cancel-in-progress: false} to each of the four workflows, keyed on the target (environment plus cloud), not on github.ref. While doing so, re-key the existing deploy-workflow groups the same way, since deploy-lambda-${{ github.ref }} does not serialise two runs that share a state key but differ in ref (a workflow_dispatch of environment=dev from a feature branch versus the push-to-main dev deploy are in different groups today and share one state key).
Related
- sec(ci): lambda deploy has same unsafe failure-path state-lock delete as #438 #1269 - the
deploy-aws-lambda.ymlfailure-path lock delete; the concurrency gap is what turns that delete from "questionable" into "actively deletes another run's lock", so the two fixes belong together.
Activity
cristim commentedon Aug 13, 2026
Verified against
origin/main(not a local checkout), enumerating every job in all four workflows rather than sampling:aws-tfstate-${{ inputs.environment }}aws-tfstate-${{ inputs.environment }}gcp-tfstate-${{ inputs.environment }}azure-tfstate-${{ inputs.environment }}aws-tfstate-stagingaws-fargate-tfstate-stagingazure-tfstate-staginggcp-tfstate-stagingaws-fargate-tfstate-devThree of the four workflows are covered. #1806 and #1801 gave every state-writing job in
rollback.yml,cleanup-staging.ymlanddestroy-fargate-dev.ymla group. The jobs in those three that still have none (validate,summary,guard) touch no Terraform state, so they do not need one.database-migration.ymlis not covered at all: no top-levelconcurrency, and none on any of its five jobs.migrate-aws,migrate-gcpandmigrate-azurerunmigrate up/migrate downagainst a live database. Two dispatches against the same cloud and environment can therefore overlap, and anupracing adownis a data-loss path, not merely lock contention. Thevalidatejob's staleness and step checks are evaluated before a concurrent run's migrate step lands, so they do not close this either.Note that these jobs are not Terraform state writers (they only run
terraform initplusterraform output -rawto read an endpoint), so the*-tfstate-*groups from #1806 / #1801 are the wrong key here. The group needs to identify the database being migrated, i.e. cloud plus environment.Keeping this open, scoped down to
database-migration.yml.cristim commentedon Aug 13, 2026
Correcting two things I stated earlier in this thread, plus two observations from the review of #1816. The fix itself is unchanged and still correct; these are corrections to the reasoning around it.
1. My "data-loss path" framing was overstated
I wrote earlier that two overlapping dispatches could interleave and that an
upracing adownis a data-loss path. That overstates it.golang-migratev4.19.1's postgres driver takes an unconditionalpg_advisory_lockinsideLock(), and that version exposes nox-no-lockoption (the workflow'sDB_URLsets no bypass either). So two concurrentmigrateruns against one database do not corruptschema_migrations. The second blocks, with the driver's own comment reading "This will wait indefinitely until the lock can be acquired."What the concurrency group actually buys, and it is still worth having:
cancel-in-progress: falseprevents the genuine dirty-state risk, which comes from cancelling mid-migrate, not from concurrency itselfSo the residual hazard is smaller than this issue originally claimed. That cuts toward shipping the fix, not away from it, but the claim should not be carried forward into future work.
2. An evicted migration does NOT report a green run
A comment in the first version of #1816 asserted that because the
summaryjob never exits non-zero, an evicted queued migration "leaves the run green". That is false, and it is being removed from the PR.Established empirically rather than from intuition: across roughly 700 GitHub Actions runs in five repositories, a cancelled job never once coexisted with a
successrun conclusion. In 109 runs matching this exact shape (at least onesuccessjob, at least onecancelledjob, and anif: always()job that still ran), the run conclusion wascancelled109 times andsuccesszero times.An evicted migration therefore surfaces as a cancelled run, which is visible. There is no silent-failure path here.
3. These jobs appear never to have run
gh api repos/LeanerCloud/CUDly/environmentsreturns 5 environments:aws-fargate-dev,aws-fargate-staging,azure-,dev,gcp-. None ofaws-db-*,gcp-db-*orazure-db-*. GitHub auto-creates a deployment environment when a job referencing it starts, which is demonstrably howazure-andgcp-came to exist, so their absence implies nomigrate-*job has ever started.That is consistent with LeanerCloud/cloud-commitments-platform#114:
terraform/environments/aws/backend.tfdeclares a partialbackend "s3"whose config comes from-backend-config=backends/<env>.tfbackend, and this workflow runs a bareterraform init, which cannot select a state non-interactively.Two consequences: the concurrency key is right in principle (one state per environment means one database per environment), but that mapping only becomes real once LeanerCloud/cloud-commitments-platform#114 lands; and there is no runtime evidence available for this change, so static enumeration is the ceiling of what can be verified here.
4. This does not cover the application's own migration path
internal/server/app.go:519wiresrunMigrationsFunc: migrations.RunMigrations, so the server runs migrations at startup. A Lambda, Cloud Run or Container App cold start concurrent with a workflow dispatch is a second writer that no GitHub concurrency group can serialize. The advisory lock in point 1 is what protects that case.Out of scope for this issue, noted so that "migration concurrency: fixed" is not later read as covering it.