From af1233f14a162db38a0bf81419f165dc17b23344 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:07:01 -0500 Subject: [PATCH] fix(ci): serialise release-tag across concurrent merges The workflow-level concurrency group is keyed per-commit (github.sha, ci.yaml:19-28) so unrelated pushes never block each other -- but that also means two merges landing close together run two concurrent release-tag jobs, each force-pushing its own commit to v1. If the older commit's job finishes last, v1 regresses to a stale-but-green commit and stays there until the next merge corrects it forward. Bounded blast radius (never a red commit, self-heals on the next merge), but a silently wrong v1 is the exact failure #27 exists to end. Added a job-level `concurrency:` on release-tag with a fixed group name and `cancel-in-progress: false`. Confirmed this is additive to the workflow-level group, not a replacement, by reading gitea's source at the v1.27.2 tag this instance runs (`tea api version`): run-level and job-level concurrency are separate model fields (ActionRunAttempt.ConcurrencyGroup vs ActionRunJob.ConcurrencyGroup), evaluated by separate functions (EvaluateRunConcurrencyFillModel vs EvaluateJobConcurrencyFillModel, services/actions/concurrency.go) and enforced by separate cancellation paths (CancelPreviousJobsByRunConcurrency in models/actions/run.go:364 vs CancelPreviousJobsByJobConcurrency in models/actions/run_job.go:641) -- job_emitter.go's checkRunConcurrency checks both groups independently (services/actions/job_emitter.go:210-236). A fixed, non-sha group name is what serialises release-tag across commits without touching the per-sha grouping every other job still relies on. `cancel-in-progress: false` matters because Gitea's default queue behaviour (documented same as GitHub's: a new queued job supersedes an older *queued* one in the same group, but not a *running* one) already gives the newest commit's push priority once the running job clears; cancelling the running job too would abandon whichever merge is currently pushing mid-flight, the same defect wearing different clothes. Re-verified: YAML parses, shellcheck clean, `bash scripts/selftest.sh` all 6 suites green (67 prune-cache assertions, unchanged by this commit). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb --- .gitea/workflows/ci.yaml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 70053c0..7225c59 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -113,6 +113,28 @@ jobs: if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 2 + # The workflow-level group above is keyed per-commit (github.sha) so + # unrelated commits' CI never blocks each other -- which also means two + # merges landing close together run two concurrent release-tag jobs, each + # force-pushing its OWN commit to v1. If the older one finishes last, v1 + # regresses to a stale-but-green commit until the next merge corrects it. + # + # A job-level `concurrency:` is a second, independent group scoped to + # this job alone -- it does not replace the workflow-level one, it adds + # to it (confirmed by reading gitea's source at the v1.27.2 tag this + # instance runs: run-level and job-level concurrency are separate model + # fields, evaluated and enforced by separate functions -- + # CancelPreviousJobsByRunConcurrency vs CancelPreviousJobsByJobConcurrency + # in models/actions/{run,run_job}.go -- not one overriding the other). + # A fixed, non-sha group name is what serialises across commits: + # `cancel-in-progress: false` keeps a running push from being interrupted + # by a newer one, and Gitea's default "single pending slot" behaviour + # (same as GitHub's) already supersedes any older QUEUED attempt with the + # newest one once the running job clears -- so this can delay v1 landing + # on the newest commit, never regress it to an older one. + concurrency: + group: release-tag-v1 + cancel-in-progress: false # Requests write access from the run's built-in token (see README's # Versioning section for what's actually verified about it). Without # this the checkout below still succeeds -- it's the push that would be