The fleet is uniform on jq 1.7 per the job logs; 1.6 stays in the set only because the image tag is unpinned. Real-jq 1.6 coverage is currently absent, and the shims cover only the empty-input -e quirk, not the NUL or parse-error ones. Measurement dated by the job date. Remaining "runner's jq 1.6" comments reworded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: f9ce6bc6-5b36-491a-8d72-89c1f70dd3e1
565 lines
37 KiB
YAML
565 lines
37 KiB
YAML
name: PR Gates
|
|
|
|
# Fast, git-only PR gates split out of docker-build.yml into a dedicated `on: pull_request`
|
|
# workflow (ersatztv#535) so they are NEVER created on a tag/main push.
|
|
#
|
|
# WHY THIS FILE EXISTS. These checks are cheap `checkout + git diff` gates (or, for `script-tests`,
|
|
# checkout + pytest): they carry no `container:`, run on the `small` lane (git-only, 1 GiB;
|
|
# server-management#639), and are PR-only.
|
|
# While they lived in docker-build.yml — which also triggers on push to main and on `v*` tags —
|
|
# Gitea still DISPATCHED them as runner tasks on every such push to evaluate the `if:` skip, because
|
|
# **Gitea dispatches a job as a runner task even when its `if` skips it** (docs/ci-cd.md -> the
|
|
# `small` lane). On the v26.12.0 release tag those dispatched skip-tasks wedged in act's setup phase
|
|
# and were killed by a runner restart mid-setup, so they reported `failure` (no logs) and reddened
|
|
# the tag's overall commit status even though the release built, scanned, and deployed fine
|
|
# (ersatztv#535). The two PR-only jobs on `ubuntu-latest` (`api-docs`, `format`) carry the identical
|
|
# `if:` and skipped cleanly on the same tag — the job logic was never the problem; the kill happens
|
|
# in the dispatch window before any step or `if:`-skip runs.
|
|
#
|
|
# Gitea evaluates a workflow's TRIGGER before creating any job, so a `pull_request`-only workflow
|
|
# produces ZERO jobs on a tag/main push: no dispatch, no kill, no spurious red. That is the whole
|
|
# fix. The per-job `if: github.event_name == 'pull_request'` guards are kept as belt-and-suspenders
|
|
# (they also encode "these steps need a PR base_ref"; harmless given the trigger).
|
|
#
|
|
# These stay on `runs-on: small` and carry NO CI toolchain image pin, so `ci-image-pin`'s grep of
|
|
# docker-build.yml still validates the five pin-bearing jobs (test/migrations/functional-e2e/
|
|
# api-docs/format) that remain there. None of these jobs are required checks — branch protection
|
|
# requires only `Build & test (.NET)`, `EF migration integrity` and `review-verdict/h10` — so
|
|
# relocating them (which changes their status-context prefix from "Build ErsatzTV Image / …" to
|
|
# "PR Gates / …") does not affect merges. See docs/ci-cd.md -> "PR gates workflow".
|
|
|
|
on:
|
|
pull_request:
|
|
|
|
# git-only host-runner jobs: no `container:`, so the runner default shell would be bash anyway, but
|
|
# declare it explicitly — ci-image-pin uses `mapfile`/`set -o pipefail`, which die under dash.
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
|
|
# Per-ref: a new push to the PR supersedes its in-flight gate run. Only runs on PRs, so always cancel.
|
|
concurrency:
|
|
group: ersatztv-pr-gates-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
# Explicit token scope (ersatztv#748) so the owner-level Actions default can move to Restricted
|
|
# (server-management#714). Declaring `permissions:` is EXHAUSTIVE, not additive: a unit omitted here
|
|
# is NOT granted, and that holds at any owner default — it is not conditional on Restricted being on.
|
|
# Only `review-verdict.yml` needs write; it declares that at the job and says why there. Full
|
|
# rationale and the per-workflow credential audit: docs/ci-cd.md -> "Workflow token scope".
|
|
# Holds no secrets at all and reads nothing from the Gitea API; the injected GITEA_TOKEN serves only
|
|
# its five `actions/checkout` steps.
|
|
permissions:
|
|
code: read
|
|
|
|
jobs:
|
|
# BLOCKING (ersatztv#390): the CI toolchain image pin in docker-build.yml must name the short sha of
|
|
# the last commit to touch the image's SOURCES (`docker/ci/**`). Read that as "the image ci-image.yml
|
|
# last published" only under the convention that every such commit is published — this job compares
|
|
# git shas and never queries the registry, so it cannot see a pin whose tag was never built or has
|
|
# been evicted. Existence is `toolchain-preflight`'s job, and the container jobs' pull is the backstop.
|
|
# Since ersatztv#744 publishing from a branch is a `workflow_dispatch`, so "was it published" is a
|
|
# human step this job does not observe.
|
|
#
|
|
# Without this detector, a PR that edits docker/ci/** ships a new image RECIPE while running its own
|
|
# jobs against the OLD pin: CI green-lights a toolchain it never executed, and once merged, main's
|
|
# Dockerfile silently disagrees with what CI runs. **Renovate actively generates exactly that PR** —
|
|
# it manages docker/ci/Dockerfile's base pins (dockerfile manager) but cannot bump an opaque
|
|
# `:<sha>` in `container.image`, so it would leave the pin behind every time.
|
|
#
|
|
# Failing here forces the documented two-step (docs/ci-cd.md -> "CI toolchain image"): get the
|
|
# Dockerfile change published as `:<sha>`, then update the pin to that sha. Since ersatztv#744 the
|
|
# publish half of that two-step is a `workflow_dispatch` on the branch rather than a side effect of
|
|
# the push — ci-image.yml's `push` trigger is now `branches: [main]`. Seconds-long git+grep -> keep
|
|
# it off the build runners.
|
|
ci-image-pin:
|
|
name: CI image pin matches docker/ci
|
|
runs-on: small
|
|
if: github.event_name == 'pull_request'
|
|
env:
|
|
CI_JOB_ROLE: guard
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
persist-credentials: false
|
|
# need real history: `git log -- <path>` on a shallow clone can't find the last
|
|
# commit that touched the image sources
|
|
fetch-depth: 0
|
|
- name: Verify the pin matches the image-source commit
|
|
run: |
|
|
set -euo pipefail
|
|
# ci-image.yml tags the image `git rev-parse --short HEAD` of the run that built it. Only
|
|
# its filtered `push` clause requires a `docker/ci/**` change; the weekly `schedule` and a
|
|
# `workflow_dispatch` both build the selected ref's HEAD whatever it touched. So `expected`
|
|
# is not a model of every tag in the registry — it is the one tag a PR is REQUIRED to be
|
|
# pinned to: the last commit to change the image's sources.
|
|
#
|
|
# `.gitea/workflows/ci-image.yml` is deliberately NOT part of `expected` (ersatztv#744),
|
|
# and that is a DECIDED TRADEOFF, not a necessity. Keeping it is workable — dispatch the
|
|
# branch at the ci-image.yml commit, then pin it — but it prices every edit to that file,
|
|
# comments included, at a full ~2GB publish plus a five-pin bump, redone after every
|
|
# rebase. Dropping it prices the opposite risk: a change to HOW the image is built living
|
|
# ONLY in ci-image.yml (build-args, Dockerfile path, platforms) neither republishes nor
|
|
# invalidates the pin, so CI keeps running an image built by the previous recipe. The
|
|
# second was chosen because that file is edited far more often for triggers, comments and
|
|
# runner placement than for build recipe. Make a recipe change alongside a `docker/ci/**`
|
|
# edit — a comment bump suffices, and it is the ONLY remedy: pinning the workflow-only
|
|
# commit is rejected here, because `expected` is the last `docker/ci` commit.
|
|
# This pathspec and `ci-image.yml`'s `on.push.paths` MUST name the same sources; before
|
|
# #744 the shared self-reference kept them in step. Divergence is silent and green in the
|
|
# dangerous direction, so it is enforced rather than asserted:
|
|
# `scripts/tests/test_ci_image_paths_pin_agreement.py` derives BOTH lists from the two
|
|
# workflows and compares them for set equality (ersatztv#855). It takes this pathspec from
|
|
# the ASSIGNMENT below rather than from any `git log` in the job, and models only a plain
|
|
# `<dir>` against `<dir>/**` there — any other spelling is refused rather than compared.
|
|
# Change this pathspec and that guard goes red until `on.push.paths` follows.
|
|
# See docs/ci-cd.md -> "Publishing from a branch is a dispatch, not a push".
|
|
#
|
|
# Compare RESOLVED FULL shas, never the abbreviations: git auto-scales abbreviation length
|
|
# with the repo's object count, so the tag built in CI from a `fetch-depth: 1` shallow clone
|
|
# is 7 chars while `%h` here (full clone) is 8. Comparing those strings would fail always.
|
|
expected="$(git log -1 --format=%H -- docker/ci)"
|
|
mapfile -t pins < <(grep -oE 'ersatztv-ci:[0-9a-f]+' .gitea/workflows/docker-build.yml | cut -d: -f2 | sort -u)
|
|
echo "Image sources last changed in: ${expected}"
|
|
echo "Pins found in docker-build.yml: ${pins[*]} (${#pins[@]} distinct)"
|
|
if [ "${#pins[@]}" -eq 0 ]; then
|
|
echo "::error::No ersatztv-ci pin found in docker-build.yml at all. Every container: job must pin ersatztv-ci:<7-char-sha>; if the grep pattern stopped matching, fix it here too (docs/ci-cd.md -> 'CI toolchain image')."
|
|
exit 1
|
|
fi
|
|
if [ "${#pins[@]}" -ne 1 ]; then
|
|
echo "::error::docker-build.yml pins MORE THAN ONE ersatztv-ci tag (${pins[*]}). All jobs must pin the same image — bump them together."
|
|
exit 1
|
|
fi
|
|
# LENGTH is a separate invariant from CORRECTNESS, and only this check covers it
|
|
# (ersatztv#594). The resolve + staleness checks below compare RESOLVED shas, so a
|
|
# 8/9/10-char abbreviation of the right commit sails through them green — while
|
|
# matching NO tag in the registry, because ci-image.yml tags with
|
|
# `git rev-parse --short HEAD` under `fetch-depth: 1`, which always yields exactly 7.
|
|
# The failure would otherwise surface far downstream as all five `container:` jobs
|
|
# dying at image-pull with `manifest unknown`, which reads like a registry outage.
|
|
# This is an easy mistake to make: the natural local command prints 8 chars.
|
|
#
|
|
# Deliberately a literal 7, not a derived `git rev-parse --short=7`: in this full
|
|
# clone git may widen an ambiguous abbreviation past 7, which would demand a pin
|
|
# ci-image.yml can never publish — the exact clone-depth asymmetry noted above.
|
|
# `${expected:0:7}` is plain string truncation, so it is safe to suggest.
|
|
#
|
|
# ESCAPE HATCH, if you are ever stuck: this makes 7 mandatory, so if `${expected:0:7}` ever
|
|
# became an AMBIGUOUS prefix (two objects sharing it), the resolve check below would fail
|
|
# and a longer pin — previously the workaround — is now rejected here first. There is no
|
|
# in-repo remedy in that state: relax this length check in the same PR and say why. Note
|
|
# that ci-image.yml still tags with a plain `--short` (auto-scaled), so "always 7" is an
|
|
# empirical property of today's shallow clone, not an enforced invariant. Making the
|
|
# publisher emit `--short=7` is tracked as ersatztv#597. That is no longer blocked by this
|
|
# job at all: since ersatztv#744, editing ci-image.yml does NOT re-point `expected`, so a
|
|
# `--short=7` change lands like any other PR. It does need a deliberate republish to take
|
|
# effect — see the note on `expected` above.
|
|
if [ "${#pins[0]}" -ne 7 ]; then
|
|
echo "::error::CI toolchain image pin ersatztv-ci:${pins[0]} is ${#pins[0]} chars, but ci-image.yml publishes 7-char tags (it tags with 'git rev-parse --short HEAD' from a fetch-depth:1 clone). A differently-sized abbreviation still resolves to the right commit, so this would pass every other check here — but NO such tag exists in the registry, and all five container: jobs would fail at image-pull time with 'manifest unknown'. Pin exactly: ersatztv-ci:${expected:0:7} (locally: git rev-parse --short=7 HEAD). See docs/ci-cd.md -> 'CI toolchain image'."
|
|
exit 1
|
|
fi
|
|
pin_full="$(git rev-parse --verify --quiet "${pins[0]}^{commit}" || true)"
|
|
if [ -z "$pin_full" ]; then
|
|
echo "::error::The pinned CI image tag ersatztv-ci:${pins[0]} does not resolve to a commit in this repo, so it cannot correspond to an image ci-image.yml built from these sources. Rebuild the image and pin the sha it prints."
|
|
exit 1
|
|
fi
|
|
if [ "$pin_full" != "$expected" ]; then
|
|
echo "::error::CI toolchain image pin is stale: docker-build.yml pins ersatztv-ci:${pins[0]} ($pin_full), but docker/ci was last changed in $expected. Your jobs are testing an image that is NOT built from this PR's docker/ci. Publish the new :<sha> — push this commit as branch HEAD and dispatch ci-image.yml on the branch (a branch PUSH no longer publishes, ersatztv#744) — then update the pin in ALL jobs to it (docs/ci-cd.md -> 'CI toolchain image')."
|
|
exit 1
|
|
fi
|
|
echo "Pin is current: ersatztv-ci:${pins[0]} resolves to $pin_full = docker/ci's last change."
|
|
|
|
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
|
|
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
|
|
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
|
|
# cache-save issues seen on the relocated runner (server-management#570).
|
|
docs-reminder:
|
|
name: Docs update reminder
|
|
runs-on: small # seconds-long git diff; keep it off the build runners
|
|
if: github.event_name == 'pull_request'
|
|
env:
|
|
CI_JOB_ROLE: report-only
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
# `continue-on-error` for the same reason the two steps below carry it: this whole job
|
|
# is a non-blocking nudge, and an advisory red still joins the combined status the merge gate
|
|
# reads. Unmasking the fetch (ersatztv#746) makes a broken base LOUD in the log; it must not
|
|
# also make a warn-only job merge-blocking. The three jobs that genuinely gate on this diff —
|
|
# api-docs, format, decisions lifecycle — do redden on a failed fetch, which is where that
|
|
# belongs.
|
|
- name: Warn when a screen/route change skips the parity doc
|
|
continue-on-error: true
|
|
run: |
|
|
base_ref="${{ github.base_ref }}"
|
|
if ! git fetch --no-tags origin "$base_ref"; then
|
|
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
|
|
exit 1
|
|
fi
|
|
if ! changed="$(git diff --name-only "origin/${base_ref}...HEAD")"; then
|
|
echo "::error::git diff against origin/${base_ref} failed, so the changed-file set could not be computed — do not read this as 'nothing changed' (ersatztv#746). If it reports no merge base, rebase this branch onto ${base_ref}."
|
|
exit 1
|
|
fi
|
|
echo "Changed files in this PR:"; printf '%s\n' "$changed"
|
|
screen_or_route=no
|
|
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
|
|
screen_or_route=yes
|
|
fi
|
|
parity=no
|
|
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
|
|
parity=yes
|
|
fi
|
|
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
|
|
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
|
|
else
|
|
echo "Parity-doc reminder: nothing to flag."
|
|
fi
|
|
|
|
# ersatztv#784 — ADVISORY nudge for `docs.no-session-narrative`. Deliberately NON-BLOCKING and
|
|
# deliberately in this job rather than a gate of its own: it is a string predicate over prose,
|
|
# and `docs/defect-shapes-773.md` §4 argues that class must not be load-bearing. The script
|
|
# exits 0 on every path (asserted per argument shape in scripts/tests/test_check_doc_narrative.py,
|
|
# not only in prose), so this step cannot redden the run even on a hit; if you find yourself
|
|
# wanting it to fail, read the decision record first — it says no in as many words.
|
|
# `python3` is not guaranteed on the bare `small` lane (docs/ci-cd.md), and every other
|
|
# python-using job on it declares this. Without it a missing interpreter is exit 127 — a RED
|
|
# advisory job joining the combined status, which is the one thing this step must never be.
|
|
#
|
|
# Both steps OF THIS CHECK (setup-python + the narrative step; the parity nudge above has its
|
|
# own) carry `continue-on-error` because the SCRIPT exiting 0 is not the whole invariant:
|
|
# a setup-python download failure reddens the job just as effectively as a hit would, and an
|
|
# advisory red still joins the combined status the merge gate reads (ersatztv#598). Scope,
|
|
# stated rather than implied: this covers the two steps that exist to run the check. A failed
|
|
# `Checkout` is NOT covered and deliberately so — with no tree there is nothing to check, and
|
|
# a job that cannot run is a different failure from an advisory one that ran and disagreed.
|
|
# Measured on this runner (PR#811, run 2179): the job reports `success` and the commit status
|
|
# context is `success` with both steps green under `continue-on-error`.
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v5
|
|
continue-on-error: true
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Warn when a doc narrates its own revision history
|
|
continue-on-error: true
|
|
run: |
|
|
base_ref="${{ github.base_ref }}"
|
|
if ! git fetch --no-tags origin "$base_ref"; then
|
|
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
|
|
exit 1
|
|
fi
|
|
python3 scripts/check-doc-narrative.py --diff "origin/${base_ref}"
|
|
|
|
# BLOCKING (ersatztv#521, supersedes the ersatztv#303 H9 append-only mechanic): validates decision-
|
|
# record lifecycle invariants (metadata schema, one active record per key, reciprocal
|
|
# supersedes/superseded-by links, no rationale-prose rewrite without a Decisions-Edit: yes git
|
|
# trailer (ersatztv#609 — never a bare substring, which prose about the marker could arm), no record
|
|
# vanishing from the active set without an archive copy) and that the generated active catalog
|
|
# (docs/decisions/README.md) is in sync. Same validator the Husky pre-commit hook shim calls, so
|
|
# local and CI enforcement can't drift. Seconds-long git diff + parse -> keep it off the build runners.
|
|
decisions-guard:
|
|
name: decisions lifecycle
|
|
runs-on: small
|
|
if: github.event_name == 'pull_request'
|
|
env:
|
|
CI_JOB_ROLE: guard
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Validate decision lifecycle
|
|
run: |
|
|
base_ref="${{ github.base_ref }}"
|
|
if ! git fetch --no-tags origin "$base_ref"; then
|
|
echo "::error::git fetch of origin/${base_ref} failed, so this job cannot compute the changed-file set it derives its work from. That is a broken job, not an empty change set (ersatztv#746). Check the base branch still exists and that the runner can reach the repository."
|
|
exit 1
|
|
fi
|
|
PYTHONPATH=. python3 scripts/decisions_validate.py --base "origin/${base_ref}" --head HEAD
|
|
- name: Active catalog in sync
|
|
run: PYTHONPATH=. python3 scripts/build_decisions_catalog.py --check
|
|
- name: Kickoff guard
|
|
run: bash scripts/check-kickoff-guard.sh
|
|
|
|
# FAILS THE RUN on a red (ersatztv#631) — like its sibling gates here it is not (yet) a required
|
|
# status check, so it reddens the PR without hard-blocking the merge button; see the header.
|
|
# Runs scripts/tests/ — the pytest suite covering the decision-corpus
|
|
# parser/validator/catalog builder, the #610 migration-equivalence harness, the merge-consent
|
|
# exemption logic and the #622 review-verdict poster. Until #631 NOTHING executed these: no
|
|
# workflow and no Husky hook invoked pytest, so the suite guarding our merge-gating machinery was
|
|
# local-only and a regression in it was caught only by luck. `decisions-guard` above runs that
|
|
# code, but never its tests.
|
|
#
|
|
# WHY ITS OWN JOB rather than a step inside decisions-guard (which the issue proposed as the
|
|
# cheapest home): `ci.decisions-lifecycle-flake` is a STANDING instruction that a lone
|
|
# `decisions lifecycle` red is a known infra flake to be ignored — "do not investigate". Folding
|
|
# the suite into that job would make a genuine pytest regression present as exactly the red every
|
|
# session is told to wave through, which is the same silently-green failure mode #631 exists to
|
|
# close. A distinct job name keeps a real failure unambiguous.
|
|
#
|
|
# Runs UNCONDITIONALLY on every PR rather than behind a `scripts/**` path filter. The suite's
|
|
# corpus tests are fixture/tmp-repo based, but several execute REAL artifacts from other top-level
|
|
# directories: test_post_review_verdict.py runs `scripts/post-review-verdict.sh`,
|
|
# test_merge_consent_exemption.py runs `.claude/hooks/pretooluse-merge-consent.sh`, and since
|
|
# ersatztv#845 test_post_review_verdict.py ALSO reads `.gitea/workflows/review-verdict.yml` —
|
|
# the writer derives the H10 allow-list from it, so editing that literal changes the suite's
|
|
# outcome. Its true input set therefore spans at least three top-level directories, and this
|
|
# enumeration is the kind that goes stale: a `scripts/**` filter would silently miss a
|
|
# `.claude/hooks/**` or `.gitea/workflows/**` edit. The reason is the INPUT SET, not the cost —
|
|
# the suite was ~10s when that was decided and is minutes now, and filtering on `scripts/**`
|
|
# would still be wrong.
|
|
prove-fix:
|
|
name: "Fix proofs (Proves trailers)"
|
|
runs-on: small
|
|
if: github.event_name == 'pull_request'
|
|
env:
|
|
CI_JOB_ROLE: guard
|
|
steps:
|
|
- name: Checkout
|
|
# Full history: prove-fix.sh reverts each commit against its PARENT, so a shallow
|
|
# clone would leave it unable to resolve `<sha>^` and it would refuse every commit.
|
|
uses: actions/checkout@v4
|
|
with:
|
|
persist-credentials: false
|
|
fetch-depth: 0
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.x'
|
|
- name: Install test dependencies
|
|
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
|
|
# OPT-IN BY TRAILER, deliberately. Requiring `Proves:` on every commit would block
|
|
# docs, CI and refactor commits that have no code side to revert, and a gate that
|
|
# blocks ordinary work gets disabled — which is how a check ends up running nowhere
|
|
# (#631). So the trailer is the AUTHOR'S CLAIM, and this job checks claims: write
|
|
# one and it must hold. Coverage is therefore honest rather than assumed, and
|
|
# `docs/decisions/records/testing/fix-ships-a-witnessed-red-test.md` says so.
|
|
- name: Prove every commit that claims a proof
|
|
run: |
|
|
set -uo pipefail
|
|
base="${{ github.event.pull_request.base.sha }}"
|
|
head="${{ github.event.pull_request.head.sha }}"
|
|
echo "range: $base..$head"
|
|
|
|
# Capture and VALIDATE the enumeration before looping. `for sha in $(git ...)`
|
|
# swallows a git failure: the command substitution yields nothing, the loop body
|
|
# never runs, and the job reports "0 claims" green. Fail-open enumeration in the
|
|
# thing that decides what gets checked is the defect this job exists to catch.
|
|
if ! shas="$(git rev-list "$base".."$head")"; then
|
|
echo "::error::git rev-list failed for $base..$head — cannot enumerate commits," \
|
|
"so this job cannot assert anything. Refusing to pass."
|
|
exit 1
|
|
fi
|
|
|
|
claimed=0; proven=0; failed=0
|
|
while IFS= read -r sha; do
|
|
[ -n "$sha" ] || continue
|
|
# Trim whitespace only — NOT `xargs`, which applies quote parsing and turns a
|
|
# legitimate parametrised node id like test_x[can't] into an empty selector,
|
|
# silently dropping a real claim.
|
|
# Extract with a CHECKED status. `sel="$(git show ... )"` under `set -uo
|
|
# pipefail` but no `-e` yields an empty selector when git fails, the commit is
|
|
# skipped, and the job exits 0 having been unable to inspect a possible claim —
|
|
# fail-open in the step that decides what gets checked.
|
|
if ! raw="$(git show -s --format='%(trailers:key=Proves,valueonly)' "$sha")"; then
|
|
echo "::error::git show failed for $sha — cannot read its trailers, so this" \
|
|
"job cannot assert anything about it. Refusing to pass."
|
|
exit 1
|
|
fi
|
|
# Refuse MORE THAN ONE `Proves:` here too. prove-fix.sh has this guard, but it
|
|
# only fires when it reads the trailer itself — and this job passes the selector
|
|
# explicitly, so the guard was bypassed on the one path that actually enforces.
|
|
# Measured: a commit with two trailers reported PROVEN while the second was never
|
|
# run. Fixing the script and not its twin is how a guard reads as coverage.
|
|
# Count trailer PRESENCE, not non-empty values: `%(...valueonly)` renders a bare
|
|
# `Proves:` as an empty line, so counting non-empty lines misses a commit whose
|
|
# FIRST trailer is empty — `sel` then comes out empty and the commit is skipped
|
|
# in silence, with a real second selector never checked. Fail-open in CI while
|
|
# the script is fail-closed is the same asymmetry this guard exists to remove.
|
|
present="$(git show -s --format='%(trailers:key=Proves)' "$sha")"
|
|
if [ "$(printf '%s\n' "$present" | grep -c .)" -gt 1 ]; then
|
|
claimed=$((claimed + 1)); failed=$((failed + 1))
|
|
echo "::error::commit $sha carries more than one 'Proves:' trailer; only the" \
|
|
"first would be checked, so the rest would read as proven without ever" \
|
|
"running. Use a single selector."
|
|
continue
|
|
fi
|
|
sel="$(printf '%s\n' "$raw" | head -1 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
# A trailer that is PRESENT but empty is a claim with no selector. Refuse it
|
|
# loudly; skipping it silently would let the job report "no claims" for a PR that
|
|
# made one.
|
|
if [ -n "$present" ] && [ -z "$sel" ]; then
|
|
claimed=$((claimed + 1)); failed=$((failed + 1))
|
|
echo "::error::commit $sha carries a 'Proves:' trailer with no selector."
|
|
continue
|
|
fi
|
|
[ -n "$sel" ] || continue
|
|
claimed=$((claimed + 1))
|
|
|
|
# A merge commit has several parents, so "before this change" is ambiguous.
|
|
# prove-fix.sh refuses them; catch it here with a clearer message rather than
|
|
# letting the trailer be silently skipped (which --no-merges used to do).
|
|
if [ "$(git rev-list --parents -n 1 "$sha" | wc -w)" -gt 2 ]; then
|
|
failed=$((failed + 1))
|
|
echo "::error::commit $sha is a MERGE carrying 'Proves: $sel'. Put the trailer" \
|
|
"on the commit that carries the fix — a merge has no single 'before'."
|
|
continue
|
|
fi
|
|
|
|
echo "::group::prove $sha -> $sel"
|
|
if bash ./scripts/prove-fix.sh "$sha" "$sel"; then
|
|
proven=$((proven + 1)); echo "PROVEN $sha"
|
|
else
|
|
rc=$?
|
|
failed=$((failed + 1))
|
|
echo "::error::commit $sha claims 'Proves: $sel' but prove-fix.sh exited $rc." \
|
|
"A claimed proof that does not hold is worse than none — it reads as" \
|
|
"coverage. Strengthen the test until reverting the fix reddens it, or" \
|
|
"drop the trailer."
|
|
fi
|
|
echo "::endgroup::"
|
|
done <<< "$shas"
|
|
|
|
echo "commits claiming a proof: $claimed (proven $proven, failed $failed)"
|
|
if [ "$claimed" -eq 0 ]; then
|
|
echo "::notice::No commit in this PR carries a 'Proves:' trailer, so nothing was" \
|
|
"verified here. That is allowed — the trailer is opt-in — but it means this" \
|
|
"job asserts NOTHING about this PR. Do not read its green as fix coverage."
|
|
fi
|
|
[ "$failed" -eq 0 ]
|
|
|
|
script-tests:
|
|
name: Script lint and tests (ruff + pytest)
|
|
runs-on: small
|
|
if: github.event_name == 'pull_request'
|
|
env:
|
|
CI_JOB_ROLE: guard
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
persist-credentials: false
|
|
- name: Set up Python
|
|
uses: actions/setup-python@v5
|
|
with:
|
|
python-version: '3.x'
|
|
# Preflight, not an install (ersatztv#390 removed run-time `apt-get` from CI on purpose).
|
|
# Two consumers need `git`: the lint steps below derive their population from `git ls-files`,
|
|
# and test_post_review_verdict.py / test_merge_consent_exemption.py exec the REAL
|
|
# post-review-verdict.sh / pretooluse-merge-consent.sh. `curl` those tests shim on PATH; `jq`
|
|
# and `git` they do NOT. It stays AHEAD of the lint steps, not merely ahead of pytest: without
|
|
# it, a missing git reaches the lint steps as an empty population, which they report as a
|
|
# population problem. One actionable line beats a misdirected one, and beats the wall of
|
|
# unattributable assertion failures the suite produces without git.
|
|
- name: Preflight external tools
|
|
run: |
|
|
if ! command -v git >/dev/null 2>&1; then
|
|
echo "::error::script-tests needs git on PATH but it is absent. The lint steps derive" \
|
|
"their population from it and the suite execs real shell scripts that use it." \
|
|
"Bake it into the runner image rather than apt-get installing here (ersatztv#390)."
|
|
exit 1
|
|
fi
|
|
echo "Preflight OK: $(git --version)"
|
|
# ersatztv#780. Lint runs EARLY — after the git preflight it depends on, but before the test
|
|
# dependencies, the jq preflight and the ~4-minute pytest run. A style red therefore arrives in
|
|
# seconds, and, more importantly, the lint does not sit behind `Preflight jq version`: that is
|
|
# an `--expect` tripwire, so a runner jq bump would take the lint dark for as long as the jq
|
|
# contract is broken, under a red that says "jq".
|
|
#
|
|
# The version is PINNED: an unpinned ruff makes the verdict a function of whenever the job ran
|
|
# — the same environment-divergence the committed ruff.toml exists to close. Bumping it is a
|
|
# deliberate PR (new rules may fire), exactly like the jq pin below. `pytest`/`pyyaml` are
|
|
# deliberately NOT pinned: a pytest release does not add assertions to your suite, a ruff
|
|
# release adds rules to your lint.
|
|
- name: Install ruff
|
|
run: python3 -m pip install --disable-pip-version-check --quiet 'ruff==0.12.11'
|
|
# POPULATION. Both steps lint an EXPLICIT list from `git ls-files`, never `ruff check .`, and
|
|
# pass `--no-force-exclude`. Measured with ruff 0.12.11 and `exclude = ["scripts/**"]` — a
|
|
# per-FILE pattern, because `exclude` matches per file: a bare `["scripts"]` still works at the
|
|
# top level but matches nothing under `[lint]`/`[format]`. The subject is a planted tracked file
|
|
# holding an unused import, a hardcoded credential and a formatting error. GREEN means the gate
|
|
# was silently off:
|
|
#
|
|
# DISCOVERY FORM EXPLICIT FORM (what ships)
|
|
# exclude scope check . format --check . check format --check
|
|
# top-level GREEN GREEN red red
|
|
# [lint] GREEN red red red
|
|
# [format] red GREEN red red
|
|
# top + force-exclude GREEN GREEN red red <- with the flag
|
|
# GREEN GREEN <- without it
|
|
#
|
|
# Only the top-level scope empties BOTH discovery commands; `[lint]` empties `check` and
|
|
# `[format]` empties `format --check`, so in those two the job would still redden on the other
|
|
# step. `[format]` is where a line appended to ruff.toml lands, by TOML rules. `include = []`,
|
|
# `extend-exclude` and a nested `scripts/ruff.toml` behave the same way and are equally inert
|
|
# against the explicit form. The last row is the whole reason for `--no-force-exclude`:
|
|
# `force-exclude = true` re-applies excludes to explicitly-passed paths, and is the one setting
|
|
# that reaches explicitly-passed paths at all.
|
|
#
|
|
# `ruff check .` over an empty tree exits **0** with only a stderr warning, so every GREEN above
|
|
# is a gate that was switched off without a red.
|
|
#
|
|
# This also derives the population from source rather than from the filesystem
|
|
# (docs/decisions/records/testing/guard-derives-population-from-source.md) and covers
|
|
# tracked-but-gitignored files, which `ruff check .` skips. The empty-population arm is the
|
|
# anti-vacuity check: a completeness check whose population is empty reports that it proved
|
|
# everything. What it does NOT cover: an emptied RULE set. `select = []` silences every selected
|
|
# rule, so the `ruff check` step goes green over any lint violation (a syntax error still reds)
|
|
# while printing a reassuring file count.
|
|
# `ruff format --check` is unaffected, because formatting is not rule-selected. So half the
|
|
# gate is killable by a config edit, and only a human reading that edit catches it.
|
|
- name: Lint scripts (ruff check)
|
|
run: |
|
|
mapfile -d '' -t PYFILES < <(git ls-files -z '*.py' '*.pyi' '*.ipynb')
|
|
if [ "${#PYFILES[@]}" -eq 0 ]; then
|
|
echo "::error::the lint population is EMPTY — git tracks no Python files. Either the" \
|
|
"checkout is wrong or the glob is. A lint over nothing passes; see ersatztv#780."
|
|
exit 1
|
|
fi
|
|
echo "Linting ${#PYFILES[@]} tracked Python files"
|
|
python3 -m ruff check --no-force-exclude -- "${PYFILES[@]}"
|
|
- name: Lint scripts (ruff format --check)
|
|
run: |
|
|
mapfile -d '' -t PYFILES < <(git ls-files -z '*.py' '*.pyi' '*.ipynb')
|
|
if [ "${#PYFILES[@]}" -eq 0 ]; then
|
|
echo "::error::the format population is EMPTY — git tracks no Python files. See ersatztv#780."
|
|
exit 1
|
|
fi
|
|
echo "Format-checking ${#PYFILES[@]} tracked Python files"
|
|
python3 -m ruff format --check --no-force-exclude -- "${PYFILES[@]}"
|
|
# pytest + PyYAML. PyYAML is NOT a contradiction of the dependency-free decisions READ path:
|
|
# `decisions_lib._read_frontmatter` is hand-written precisely so validation runs where nothing
|
|
# is installed, but the one-shot WRITE path `migrate_decisions_split.py` uses PyYAML by
|
|
# design — and `test_migration_equivalence.py` imports that module, so the suite needs it.
|
|
# `pytest` and `yaml` are the complete third-party set, established by an AST import scan over
|
|
# all of scripts/ rather than by reading the files that seemed relevant — reading only those
|
|
# yields "pure stdlib", a claim that passes locally on a machine that happens to have PyYAML
|
|
# and goes red in CI on a collection error.
|
|
- name: Install test dependencies
|
|
run: python3 -m pip install --disable-pip-version-check --quiet pytest pyyaml
|
|
# jq gets its OWN step because its VERSION, not merely its presence, is load-bearing
|
|
# (ersatztv#648). `--expect` makes this a TRIPWIRE over an accepted SET of versions: the
|
|
# set is exactly {1.6, 1.7} (ersatztv#935). Both `small` runners that took PR #933/#934 reported
|
|
# 1.7 (2026-10-28), but both map to the floating runner-images:ubuntu-latest image with no digest
|
|
# pin, so a host may hold an older pull and 1.6 stays in the set. Any other version goes red. No
|
|
# known runner reports 1.6 today, so real-jq 1.6 coverage of scripts/tests is currently absent.
|
|
# server-management#1080 proposes a fleet digest pin; when it settles one version, narrow this
|
|
# to that version.
|
|
#
|
|
# The pin lives HERE and deliberately NOT in review-verdict.yml: that workflow writes the
|
|
# branch-protection-required `review-verdict/h10` status, so pinning a version there would turn
|
|
# any jq bump on the runner into a repo-wide merge deadlock. It gets the floor-only mode.
|
|
# See docs/ci-cd.md -> "The jq contract".
|
|
- name: Preflight jq version
|
|
run: ./scripts/jq-preflight.sh --expect 1.6,1.7
|
|
- name: Run scripts/tests
|
|
run: PYTHONPATH=. python3 -m pytest scripts/tests -q
|