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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
156 lines
7.7 KiB
YAML
156 lines
7.7 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 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
|
|
# rejected, which is a red job, not a silent no-op.
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
# 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
|
|
run: |
|
|
git tag -f v1 "${{ github.sha }}"
|
|
git push --force origin v1
|