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