Files
gitdan-actions/.gitea/workflows/ci.yaml
T
claudeandClaude Sonnet 5 dc1e6317c6 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
2026-09-22 12:24:27 -05:00

184 lines
9.3 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 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
# 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
# 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