From dc1e6317c651429c6e58dd027cb66a6143b67c4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:24:27 -0500 Subject: [PATCH] fix(ci): make the v1 push monotonic, not just mutually exclusive The concurrency group added in af1233f only excludes two release-tag jobs that are simultaneously Running/Waiting/Blocked (services/actions/clear_tasks.go:64-80, models/actions/run_job.go:641-660 at the v1.27.2 tag this instance runs) -- it has no notion of commit order between jobs that never overlap. On a 2-slot runner shared across 4 repos, with a multi-minute selftest gating each release-tag job, two merges landing close together routinely finish in the opposite order from the pushes that triggered them: if the newer commit's job completes and exits first, the older commit's job later finds no live holder in the group, is not blocked, and force-pushes v1 backward to itself. The concurrency comment's "never regress it to an older one" and af1233f's commit message both asserted the opposite -- true of the simultaneous case the guard covers, false of the staggered one it doesn't, so authored-false rather than drift. Added a merge-base check before the push: skip if v1 already points at this commit or a descendant of it (`--is-ancestor` treats a commit as its own ancestor, so "at" and "ahead" are the same branch). A v1 that doesn't exist yet, or shares no history with this commit, falls through to the push -- the guard only ever skips, never fails. Needs `fetch-depth: 0` on the checkout: actions/checkout's own description for that value is "all history for all branches and tags", and its source (dist/index.js: fetchDepth <= 0 selects getRefSpecForAllHistory, which includes the tags refspec) confirms tags are fetched as part of that, not gated behind the separate fetch-tags input -- so refs/tags/v1 and the history behind it are both guaranteed present locally without a second fetch step. Rewrote both false claims: the concurrency comment now says what the group actually bounds (simultaneous competing pushes, not completion order), and states plainly that the guard below is what makes the outcome order-independent. Verified the check's five cases (no tag yet, tag ahead of this commit, tag behind this commit, tag equal to this commit, unrelated history) against a real scratch git repo, extracting the exact condition used in the workflow step -- all five resolved as intended (skip only when v1 is already at or ahead). The workflow step itself cannot be exercised outside a real Actions run. bash scripts/selftest.sh: all 6 suites green (67 prune-cache assertions, unchanged). shellcheck -x --source-path=scripts scripts/*.sh: clean -- note this does not cover the inline `run:` shell in ci.yaml, which shellcheck was never wired to check in this repo (verified against the CI job itself, which shellchecks only scripts/*.sh). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb --- .gitea/workflows/ci.yaml | 48 +++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 7225c59..c4c8fbf 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -115,10 +115,7 @@ jobs: 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. - # + # merges landing close together can run two concurrent release-tag jobs. # 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 @@ -126,12 +123,13 @@ jobs: # 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. + # This serialises jobs that are actually running or queued AT THE SAME + # TIME -- it has no notion of commit order between jobs that never + # overlap. Two release-tag runs on a 2-slot, 4-repo runner with a + # multi-minute selftest ahead of each can easily not overlap at all, + # finishing in either order regardless of push order. The step below is + # what makes the OUTCOME order-independent; this group only bounds how + # much work is wasted getting there. concurrency: group: release-tag-v1 cancel-in-progress: false @@ -142,14 +140,44 @@ jobs: permissions: contents: write steps: + # fetch-depth: 0 fetches full history AND all tags (actions/checkout's + # own description: "0 indicates all history for all branches and + # tags") -- REQUIRED so refs/tags/v1 and the ancestry behind it are + # both present locally for the merge-base check below, regardless of + # which commit this run happens to be built from. - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + # The concurrency group above only excludes a SIMULTANEOUS competing + # push; it does nothing for two release-tag jobs that never overlap + # and finish in the opposite order from the merges that triggered + # them -- which a shared 2-slot runner and a multi-minute selftest + # ahead of this job make routine, not rare. So the push itself has to + # be safe regardless of completion order: skip whenever v1 already + # points at this commit or a descendant of it, rather than trusting + # this run to be the last one to finish. `--is-ancestor` treats a + # commit as its own ancestor, so "already at" and "already ahead" are + # one case. A v1 that doesn't exist yet, or that shares no history + # with this commit, falls through to the push below -- the guard is + # only ever a reason to skip, never a reason to fail. + - name: Skip if v1 already at or ahead of this commit + id: check + run: | + if git rev-parse -q --verify refs/tags/v1 >/dev/null \ + && git merge-base --is-ancestor "${{ github.sha }}" refs/tags/v1; then + echo "v1 already at or ahead of ${{ github.sha }} -- nothing to do" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi # Lightweight tag, matching what v1 already is (`git cat-file -t v1` # reports `commit`, not `tag`) -- no identity needed to move it, only # to push it. - name: Force v1 to this commit + if: steps.check.outputs.skip != 'true' run: | git tag -f v1 "${{ github.sha }}" git push --force origin v1