Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 25s
review-verdict/h10 Review-verdict: MERGEABLE @ 5e15dd1 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 16m51s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m44s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
`ci-image.yml`'s `on.push.paths` decides which pushes to `main` publish a toolchain image; `ci-image-pin`'s `git log` pathspec decides what the pin must name. #744 removed the shared self-reference that kept them in step, leaving the agreement carried by three prose comments, and divergence is silent and green in the dangerous direction. The guard derives both lists from the workflow documents and compares them for set equality in both directions. The comparison is deliberately narrow: it accepts a publish entry spelled exactly `<dir>/**` against a pathspec entry spelled exactly `<dir>`, segments restricted to `[A-Za-z0-9._-]`, and raises on every other spelling rather than deciding what that spelling would have selected. That narrowness is the substance. Measured against Gitea 1.27.1's compiler and real git, a bare `docker/ci` in `paths:` compiles to an anchored `^docker/ci` and selects none of the directory's contents while the git pathspec `docker/ci` selects all of them; `<file>/**` matches nothing while the pathspec `<file>` tracks the file; a leading `/` is literal to Gitea while git refuses it outright. A canonicaliser mapping the two dialects onto one string form was built twice and defeated twice, each repair surfacing another spelling, so it was deleted rather than extended per `testing.verification-code-needs-its-own-proof`. The guard also asserts from the git index that each named path really is a directory, since `<file>/**` and the pathspec `<file>` spell the same string; takes the pathspec from the `git log` assignment rather than any `git log` in the job; and refuses a `<<` token on a code line (a herestring excluded) and a second bare `--`, because telling a path separator from an option argument needs git's option arity and refusing needs nothing. The docstring states the boundaries rather than implying coverage: the guard compares the pathspec the pin job writes and does not establish that the staleness comparison consumes it, and a descendant whose name contains a newline is matched by the git pathspec but not by the publish pattern. fixes #855
562 lines
37 KiB
YAML
562 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: the first cut of this
|
|
# job claimed "pure stdlib", passed locally on a machine that happened to have PyYAML, and
|
|
# went 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: scripts/tests exercises the jq 1.6 code path
|
|
# only because this runner ships 1.6, so an upgrade would silently delete that coverage — and
|
|
# the three divergences found in ersatztv#643/#647 all lived exactly there. Going red forces an
|
|
# explicit human decision instead of letting the coverage evaporate.
|
|
#
|
|
# 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
|
|
- name: Run scripts/tests
|
|
run: PYTHONPATH=. python3 -m pytest scripts/tests -q
|