The wake-one-cancel-the-rest mechanism behind the concurrency group (CancelPreviousJobsByJobConcurrency, models/actions/run_job.go, at the v1.27.2 tag this instance runs) picks its survivor from models/actions/run_job_list.go's query, which carries no `ORDER BY` -- so under three-way contention on a shared 2-slot runner, the *newest* commit's job can be the one cancelled while an older sibling survives and, correctly from its own vantage, advances v1 forward from a stale view. No push regresses v1 (the ancestor check fromdc1e631already prevented that), but the newest merge goes silently unreleased behind a cancelled job that reads as benign, not red -- exactly the failure #27 exists to end. Every job now resolves `origin/main`'s tip fresh, right before the push, instead of using `${{ github.sha }}`. Re-fetched explicitly rather than trusted from the checkout step, which can be minutes stale by this point behind its own selftest job. Every execution that reaches the push step now converges on the same target regardless of which job the concurrency group lets through, so which one wins the wake no longer matters -- the survivor pushes where any of them would have. That doesn't make the push safe on its own: two jobs can still read main at genuinely different moments if it advances between their two fetches, so whichever read the tip earlier must not overwrite the other's already-pushed, newer one. The ancestor check fromdc1e631is kept for exactly this -- its target changed (origin/main's live tip, not this job's own trigger commit) but its job didn't. What each guard now protects against, after this change: - concurrency group: stops two jobs from pushing at the same time -- wasted work now that a cancelled job costs nothing, not a correctness backstop by itself. - ancestor check: stops a job whose own fetch of the tip is stale relative to another job's already-pushed, fresher one from regressing v1. Rewrote both the job-level comments and README's Versioning section, which described "force-moves v1 to that commit" and said nothing about the concurrency group or the skip-as-success case. Re-derived the truth table against the new target (a live origin/main tip, not a fixed commit) in a scratch origin+clone: no v1 yet (push), v1 exactly at the tip (skip), the tip moved past v1 because a newer merge landed (push, to the new tip -- not stuck on any prior commit), v1 already ahead of the tip (skip, defensive), unrelated histories (push, defensive). All five resolved as intended; ran the exact condition and fetch sequence the workflow step uses, not a simulation of it. bash scripts/selftest.sh: all 6 suites green (67 prune-cache assertions, unchanged). shellcheck -x --source-path=scripts scripts/*.sh: clean -- as before, this does not cover the inline `run:` shell in ci.yaml. What remains unverifiable short of a real merge is unchanged fromdc1e631: the workflow step's actual execution inside a real Actions run, and whether the built-in token has write access at all. Neither this commit nor the one before it can exercise those. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
202 lines
10 KiB
YAML
202 lines
10 KiB
YAML
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
branches: [main]
|
|
# Spelled out only to keep `edited` in the list — naming any type replaces
|
|
# the whole default set, so the other three have to be restated.
|
|
#
|
|
# `edited`, not `ready_for_review`: draft here is the `WIP:` title prefix,
|
|
# so un-drafting is a title edit and no ready-for-review action is ever
|
|
# raised. That edit is what creates the run which lifts the `if:` skip
|
|
# below (decided once, when a run is CREATED). Do not swap it back and do
|
|
# not drop this to the bare default — either restores the bug. The accepted
|
|
# cost and the evidence are in README's Development section.
|
|
types: [opened, synchronize, reopened, edited]
|
|
|
|
# gitdan-ci runs four repos' CI on two capacity slots, and the compiler-backed
|
|
# suites below are multi-minute. A superseded run costs a slot in front of
|
|
# somebody's build, so drop it.
|
|
#
|
|
# `push` groups on `github.sha` rather than `github.ref`: a constant per-branch
|
|
# group is what let this Gitea cancel two of daniel/gitdan's merge runs
|
|
# outright while `cancel-in-progress` was gated away from `push` entirely —
|
|
# see the long note in that repo's ci.yaml. Observed on 1.26.0; the instance
|
|
# is 1.27.2 now and whether it persists is unverified, which is why every
|
|
# commit gets its own group instead of trusting the flag.
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
selftest:
|
|
name: shellcheck + selftests
|
|
# Two clauses, both load-bearing. The second skips draft PRs: Gitea sets
|
|
# draft:true when the title starts with `WIP:`, so work-in-progress pushes
|
|
# cost the shared runner nothing until the PR is un-WIP'd. The first is
|
|
# what keeps that from also skipping pushes to `main` — a `push` event has
|
|
# no `pull_request` context, so `github.event.pull_request.draft` is empty
|
|
# there and the negation alone would be unreliable. Never drop it.
|
|
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 20
|
|
# Deliberately no `container.volumes:` entry, unlike every repo that
|
|
# CONSUMES this action. The suites below build throwaway workspaces under
|
|
# `mktemp -d` and want a cold target dir every time — a persistent cache
|
|
# would make "did this run rebuild?" unanswerable, which is the question
|
|
# restore-mtimes-selftest.sh exists to ask. So this job takes no share of
|
|
# the shared CI cache disk budget.
|
|
steps:
|
|
- name: Checkout sources
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Install shellcheck
|
|
uses: taiki-e/install-action@v2
|
|
with:
|
|
tool: shellcheck
|
|
|
|
# `hardlink-clone-selftest.sh` and `restore-mtimes-selftest.sh` drive a
|
|
# real Cargo against a real scratch workspace — they are the only things
|
|
# here that verify the hardlink-aliasing and mtime-freshness behaviour
|
|
# against the compiler rather than against a fixture, and selftest.sh's
|
|
# own header says `--fast` is for iterating, not for signing off a
|
|
# change. So CI installs a toolchain and runs the full set.
|
|
#
|
|
# The scratch workspaces use path dependencies only, so nothing here
|
|
# reaches crates.io.
|
|
# Nightly first, stable second, so stable ends up the default and
|
|
# nightly is only reachable through an explicit `+nightly`.
|
|
#
|
|
# `hardlink-clone-selftest.sh`'s last scenario needs a Cargo that
|
|
# resolves freshness by CONTENT — the mode where the dep-info file
|
|
# carries per-source checksums, which is the mutation that turns a
|
|
# hardlink clone into silent stale-artifact reuse rather than a slow
|
|
# build. This step is what supplies it, and as of 2026-08-26 it does:
|
|
# the scenario ran and passed against 1.100.0-nightly.
|
|
#
|
|
# It briefly did not. Cargo PR #17382 (2026-08-22) demoted
|
|
# `-Z checksum-freshness` to a gate and gave `build.fingerprint` the
|
|
# choice, defaulting to `mtime`, so the suite — which set only the gate —
|
|
# measured a genuine INACTIVE and skipped its strongest scenario. That
|
|
# read as "upstream withdrew content freshness" and was written up here
|
|
# as this step buying nothing. It was a moved switch, not a withdrawal;
|
|
# the suite now exports both and the coverage is back. See
|
|
# daniel/gitdan#62 for the investigation.
|
|
- name: Install Rust nightly
|
|
uses: dtolnay/rust-toolchain@nightly
|
|
- name: Install Rust toolchain
|
|
uses: dtolnay/rust-toolchain@stable
|
|
|
|
# Every script, including the suites themselves. `-x` follows the
|
|
# `. cache-lib.sh` each one sources, which is where most of the logic
|
|
# being checked actually lives; without it shellcheck reports SC1091 and
|
|
# analyses each file with a hole in it.
|
|
- name: shellcheck
|
|
run: shellcheck -x --source-path=scripts scripts/*.sh
|
|
|
|
# One command, not six: selftest.sh is the entry point a developer runs,
|
|
# so a suite added there is gated here without a matching edit in this
|
|
# file.
|
|
- name: Selftests
|
|
run: bash scripts/selftest.sh
|
|
|
|
release-tag:
|
|
name: move v1 to main
|
|
# `needs:` is what makes this "after the gate is green" rather than
|
|
# merely "after a push": a failed selftest skips this job outright, so
|
|
# v1 can never advance onto a broken build. The `if:` restricts it to an
|
|
# actual push to main -- a pull_request run targeting main shares this
|
|
# workflow but has no ref worth tagging.
|
|
needs: selftest
|
|
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 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
|
|
# 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).
|
|
#
|
|
# This does NOT decide which of several contending jobs gets to push --
|
|
# Gitea wakes exactly one Blocked job in the group and cancels the rest
|
|
# outright, with no ordering on which one it picks (no `ORDER BY` in the
|
|
# query behind CancelPreviousJobsByJobConcurrency,
|
|
# models/actions/run_job_list.go). What it buys is cheaper: every
|
|
# execution that does reach the push step targets origin/main's live tip
|
|
# (below), never its own trigger commit, so it makes no difference which
|
|
# one wins -- the survivor pushes where any of them would have, and a
|
|
# cancelled job costs nothing. This group's only job is to stop more than
|
|
# one job from pushing AT THE SAME TIME, which is wasted work, not a
|
|
# correctness risk on its own.
|
|
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
|
|
# rejected, which is a red job, not a silent no-op.
|
|
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
|
|
|
|
# This run's own trigger commit (${{ github.sha }}) is deliberately not
|
|
# what gets pushed: whichever job the concurrency group above lets
|
|
# through is the one that pushes, and that choice carries no relation
|
|
# to commit recency, so every execution has to converge on the SAME
|
|
# target regardless of which job it is. `origin/main`'s live tip,
|
|
# re-fetched here rather than trusted from the checkout above (which
|
|
# can be minutes stale by this point, behind its own selftest job), is
|
|
# that common target -- read fresh, every job that reaches this step
|
|
# resolves to the same commit whenever main hasn't moved between them,
|
|
# and to whatever's newest when it has.
|
|
#
|
|
# A live target doesn't make the push itself safe on its own: two jobs
|
|
# can still read main at genuinely different moments if it advances
|
|
# between their two fetches, so the one with the earlier reading must
|
|
# not overwrite the other's already-pushed, newer one. That's what the
|
|
# ancestor check below still guards -- not "this job's stale trigger
|
|
# commit" any more, but "this job's freshly-read tip, which another
|
|
# job's fresher read may have already superseded." `--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 tip, falls through to the push -- the guard is
|
|
# only ever a reason to skip, never a reason to fail.
|
|
- name: Determine origin/main's tip and whether v1 needs to move
|
|
id: check
|
|
run: |
|
|
git fetch origin main
|
|
TIP=$(git rev-parse origin/main)
|
|
echo "tip=$TIP" >> "$GITHUB_OUTPUT"
|
|
if git rev-parse -q --verify refs/tags/v1 >/dev/null \
|
|
&& git merge-base --is-ancestor "$TIP" refs/tags/v1; then
|
|
echo "v1 already at or ahead of origin/main's tip ($TIP) -- 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 origin/main's tip
|
|
if: steps.check.outputs.skip != 'true'
|
|
run: |
|
|
git tag -f v1 "${{ steps.check.outputs.tip }}"
|
|
git push --force origin v1
|