Skip to content

Commit cc2d040

Browse files
ci: annotate failures so they are readable off the log host, and fix rustdoc links
The log endpoint is a different host from the API and is not always reachable — by a restricted network, an offline contributor, or any automation reading the checks API. A failed step whose only explanation is in a log download is therefore half a gate. scripts/ci/annotate.sh runs a command, annotates up to 20 failing lines through the API (), and re-raises the exit status. verify.yml uses it for format, clippy, both test matrices, MSRV and rustdoc. Also fixes two doc comments that linked to struct FIELDS (AggregateConfig::fail_on_defect, ProvenTestsRecord::defects): rustdoc cannot resolve a field path, so failed on an otherwise correct comment. They are code spans now. Adds the CI/CD documentation and the local mirrors of the new gates (ci, integrity, pins, proven, recon, squabble) — a gate you can only run by pushing is a gate you debug by pushing. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
1 parent 21b14d1 commit cc2d040

7 files changed

Lines changed: 337 additions & 15 deletions

File tree

‎.github/workflows/recon.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ jobs:
8888
mkdir -p "$SARIF_DIR"
8989
./target/release/panic-attack assail . \
9090
--output "$SARIF_DIR/assail.json" --quiet
91-
./target/release/panic-attack assail . --format sarif \
91+
./target/release/panic-attack --output-format sarif assail . \
9292
--output "$SARIF_DIR/assail.sarif" --quiet || true
9393
9494
- name: Checkout pons-asinorum at the pinned commit

‎.github/workflows/verify.yml‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ jobs:
5959
with:
6060
toolchain: stable
6161
components: rustfmt
62-
- run: cargo fmt --all -- --check
62+
- name: Format
63+
run: bash scripts/ci/annotate.sh rustfmt -- cargo fmt --all -- --check
6364

6465
clippy:
6566
name: cargo clippy -D warnings
@@ -76,7 +77,8 @@ jobs:
7677
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
7778
# `gui` is deliberately excluded: eframe/egui needs system libraries this
7879
# job does not install, and the GUI is opt-in. Everything else is checked.
79-
- run: cargo clippy --locked --all-targets --features http,sarif,signing -- -D warnings
80+
- name: Clippy
81+
run: bash scripts/ci/annotate.sh clippy -- cargo clippy --locked --all-targets --features http,sarif,signing -- -D warnings
8082

8183
test:
8284
name: cargo test (${{ matrix.features }})
@@ -105,9 +107,10 @@ jobs:
105107
run: |
106108
set -euo pipefail
107109
if [ -n "${{ matrix.features }}" ]; then
108-
cargo test --locked --features "${{ matrix.features }}"
110+
bash scripts/ci/annotate.sh "tests[${{ matrix.features }}]" -- \
111+
cargo test --locked --features "${{ matrix.features }}"
109112
else
110-
cargo test --locked
113+
bash scripts/ci/annotate.sh "tests[default]" -- cargo test --locked
111114
fi
112115
113116
msrv:
@@ -132,7 +135,8 @@ jobs:
132135
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
133136
with:
134137
key: msrv
135-
- run: cargo check --locked --all-targets
138+
- name: MSRV check
139+
run: bash scripts/ci/annotate.sh msrv -- cargo check --locked --all-targets
136140

137141
docs:
138142
name: cargo doc (no broken intra-doc links)
@@ -150,7 +154,8 @@ jobs:
150154
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
151155
with:
152156
key: docs
153-
- run: cargo doc --locked --no-deps --features http,sarif,signing
157+
- name: Docs
158+
run: bash scripts/ci/annotate.sh rustdoc -- cargo doc --locked --no-deps --features http,sarif,signing
154159

155160
coverage-ledger:
156161
name: Coverage ledger is honest (--fail-on-incomplete)

‎Justfile‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,89 @@ crg-badge:
268268
D) color="orange" ;; E) color="red" ;; F) color="critical" ;; \
269269
*) color="lightgrey" ;; esac; \
270270
echo "[![CRG $$grade](https://img.shields.io/badge/CRG-$$grade-$$color?style=flat-square)](https://github.com/hyperpolymath/standards/tree/main/component-readiness-grades)"
271+
272+
# ── CI/CD foundation ─────────────────────────────────────────────────────────
273+
# These mirror the workflows in .github/workflows/ so a contributor can run the
274+
# same gates locally. A gate you can only run in CI is a gate you debug by
275+
# pushing.
276+
277+
# Everything that must pass before a PR: format, lint, test, docs, integrity.
278+
ci: fmt-check lint test-local integrity
279+
280+
# cargo fmt in check mode (never rewrites a contributor's tree)
281+
fmt-check:
282+
cargo fmt --all -- --check
283+
284+
test-local:
285+
cargo test --locked
286+
287+
# CI integrity: SHA pins, lockfile coverage, allowlist coverage, YAML parse.
288+
# Needs `yq` (mikefarah) and `python3`; see scripts/ci/.
289+
integrity:
290+
#!/usr/bin/env bash
291+
set -euo pipefail
292+
command -v yq >/dev/null || { echo "integrity: yq not found (mikefarah/yq)"; exit 2; }
293+
bash .githooks/validate-sha-pins.sh
294+
python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json --check
295+
bash .githooks/validate-actions-lock.sh
296+
bash scripts/ci/check-allowed-actions.sh .github/actions-allowlist.json .github/workflows
297+
for f in .github/workflows/*.yml; do
298+
case "$f" in *.lock) continue ;; esac
299+
[ "$(yq -r '.jobs | tag' "$f")" = '!!map' ] || { echo "integrity: $f has no jobs map"; exit 1; }
300+
done
301+
echo "integrity: OK"
302+
303+
# After changing any `uses:`: re-resolve tag -> commit and rewrite the lockfile.
304+
pins:
305+
bash scripts/ci/resolve-action-shas.sh
306+
python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json
307+
308+
# ── Evidence-graded testing (proven-tests-and-benches) ───────────────────────
309+
310+
# Derive a run report from tests that actually exist, then fold it through
311+
# aggregate. Fails on a cell with no test, a test with no cell, or a claim the
312+
# report does not support.
313+
proven:
314+
#!/usr/bin/env bash
315+
set -euo pipefail
316+
cargo build --locked --release
317+
bash scripts/proven/inventory.sh /tmp/panic-attack-tests.json
318+
python3 scripts/proven/verify-ledger.py \
319+
--inventory /tmp/panic-attack-tests.json \
320+
--ledger proven/ledger.json \
321+
--root . \
322+
--out /tmp/proven-report.json
323+
./target/release/panic-attack aggregate \
324+
--proven-tests /tmp/proven-report.json \
325+
--proven-tests-root . \
326+
--fail-on-defect \
327+
--output /tmp/proven-aggregate.json
328+
329+
# ── Recon ────────────────────────────────────────────────────────────────────
330+
331+
# Scan this repository with its own analyser and compare with the debt baseline.
332+
recon:
333+
#!/usr/bin/env bash
334+
set -euo pipefail
335+
mkdir -p recon-out
336+
cargo run --release --quiet -- assail . --output recon-out/assail.json --quiet
337+
python3 scripts/recon/compare.py \
338+
--baseline recon/baseline.json \
339+
--assail recon-out/assail.json \
340+
--pons /dev/null \
341+
--no-fail
342+
343+
# ── Squabbling ───────────────────────────────────────────────────────────────
344+
345+
# Diagnose a stuck gate for PR <pr> with the estate's CI fighter.
346+
# Build it once: cargo install --git https://github.com/hyperpolymath/cicd-squabbler \
347+
# --rev 14f853fde902ab2d7631070d87a9dc9122e01013 squabble-cli
348+
squabble pr:
349+
#!/usr/bin/env bash
350+
set -euo pipefail
351+
squabble fetch hyperpolymath/panic-attack {{pr}} > /tmp/gate.json
352+
squabble diagnose /tmp/gate.json
353+
354+
# Cross-repo CI chains for this repository (read-only; exit 4 = blocking finding).
355+
squabble-chains:
356+
squabble chains . gh:hyperpolymath/standards --follow 1

‎docs/CI-CD.adoc‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// SPDX-License-Identifier: CC-BY-SA-4.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
= CI/CD for panic-attack
4+
:toc: left
5+
:toclevels: 3
6+
:sectnums:
7+
8+
== The failure this replaces
9+
10+
On 2026-10-25 this repository's CI went dark for four hours. Twelve
11+
`pull_request` workflows concluded `startup_failure`: **zero jobs, no log, no
12+
annotation, no check run**. Nothing in the GitHub UI said why, and the only
13+
diagnosis available was to read the estate's settings canon and compare it with
14+
our own workflow files.
15+
16+
Two causes, both policy-level, neither visible from the code:
17+
18+
`uses:` a reusable workflow from `metadatastician/standards`::
19+
The canonical allowlist -
20+
`rhodium-standard-repositories/actions-allowlist/allowed-actions.json` in
21+
`hyperpolymath/standards` - permits `hyperpolymath/*`. It does not permit
22+
`metadatastician/*`. `hyperpolymath/standards` and `metadatastician/standards`
23+
are the *same repository* (a rename; the lockfile records one `repo_id`), so the
24+
correct spelling is the one the allowlist covers. Six thin wrappers had been
25+
re-pointed at the new owner, and every one of them died before its first job.
26+
27+
tag-pinned `uses:`, e.g. `actions/checkout@v7.0.1`::
28+
The estate posture is `sha_pinning_required = true`. Under it, a tag-pinned
29+
`uses:` is refused **at startup** - the same opaque death. The pin is a security
30+
control, so the fix is to pin properly, not to turn the control off.
31+
32+
The lesson generalises and is the reason this document exists: *a run that never
33+
starts is invisible to every gate that watches runs*. It produces no check run,
34+
so a required context silently stops arriving and the branch looks
35+
**healthier** than a broken one. Both causes are now gated before they can bite
36+
(see <<integrity>>).
37+
38+
== The shape of the pipeline
39+
40+
[cols="2,3,2",options="header"]
41+
|===
42+
| Workflow | What it answers | When
43+
44+
| `verify.yml`
45+
| Does it build, lint, test, document and self-scan honestly? The floor that
46+
cannot be taken out by another repository.
47+
| every PR, every push
48+
49+
| `ci-integrity.yml`
50+
| Can the CI itself start? SHA pins, lockfile coverage, allowlist coverage,
51+
YAML parseability.
52+
| every PR, every push, Mondays
53+
54+
| `proven-tests.yml`
55+
| Is the suite's claimed coverage derived from tests that ran, and are its tiers
56+
honest? Plus the framework's own Idris2 suite.
57+
| ledger: every PR. framework: Mondays/dispatch
58+
59+
| `squabble-gate.yml`
60+
| Is any required context missing, and is any CI upstream dead? Runs
61+
`cicd-squabbler`.
62+
| Wednesdays, dispatch, PRs labelled `ci-squabble`
63+
64+
| `recon.yml`
65+
| What do two independent analysers say about this repository, and has any
66+
category regressed?
67+
| Tuesdays, dispatch
68+
69+
| `rust-ci.yml`, `governance.yml`, `hypatia-scan.yml`, `secret-scanner.yml`,
70+
`scorecard.yml`, `mirror.yml`
71+
| The estate's richer reusable gates (audit, coverage upload, Hypatia SARIF,
72+
governance, secret scanning).
73+
| per estate canon
74+
75+
| `e2e.yml`, `codecov`, `cargo-audit`, `chapel-ci`, `dogfood-gate`,
76+
`bridge-gate`, `pages`, `release`
77+
| Behavioural, supply-chain, contract and release checks.
78+
| per file header
79+
|===
80+
81+
=== Why `verify.yml` exists at all
82+
83+
Every estate gate except `verify` depends on `hyperpolymath/standards` being
84+
reachable *and* permitted at that instant. That is the right long-term shape -
85+
one place to configure, propagate everywhere - but it means a settings change in
86+
another repository can remove this repository's entire build signal. `verify`
87+
depends on nothing but actions this repository pins itself, so the "does it
88+
compile and pass" answer survives any policy change elsewhere.
89+
90+
<<integrity>> === CI integrity
91+
92+
`ci-integrity.yml` runs four gates, each of which corresponds to a way a
93+
workflow dies before its first job:
94+
95+
SHA pins:: `.githooks/validate-sha-pins.sh` (canon). Every third-party `uses:`
96+
names a 40-hex commit plus a `# <version>` comment.
97+
Lockfile coverage:: `.githooks/validate-actions-lock.sh` (canon), plus
98+
`scripts/ci/pin-workflow-uses.py --check`, which derives the lockfile from the
99+
workflows. A caller pinned to a commit whose lockfile has drifted dies with
100+
`failure`, 0 jobs and *no reason in the API* - which is why this is a gate and
101+
not a courtesy.
102+
Allowlist coverage:: `scripts/ci/check-allowed-actions.sh` (canon) against the
103+
vendored `.github/actions-allowlist.json`. No API, no admin: it lists exactly
104+
which `uses:` the estate policy does not cover.
105+
YAML parseability:: every workflow parses *and* declares a `jobs:` map. A file
106+
whose quoting swallowed the document parses to a scalar and never runs.
107+
108+
Maintenance: after changing any `uses:`, run
109+
110+
[source,sh]
111+
----
112+
bash scripts/ci/resolve-action-shas.sh # network: resolves tag -> commit
113+
python3 scripts/ci/pin-workflow-uses.py --map .github/workflows/action-pins.json
114+
----
115+
116+
`action-pins.json` is committed on purpose: it records which tag a pin stood for
117+
on the day it was taken, so a reviewer can tell an intentional bump from a
118+
silent re-point.
119+
120+
== Testing: evidence-graded, not declared
121+
122+
`proven/ledger.json` grades this repository's suite on
123+
`proven-tests-and-benches`' 17-category x 14-aspect lattice. The rule the
124+
framework exists to enforce is that **coverage is derived from executed tests,
125+
never declared**, so:
126+
127+
* `scripts/proven/inventory.sh` asks the test runner what tests exist, per
128+
binary;
129+
* `scripts/proven/verify-ledger.py` fails when a cell selects no executed test,
130+
when an executed test is claimed by nothing, or when a stronger tier is
131+
claimed than the evidence supports;
132+
* the resulting report is fed to `panic-attack aggregate --proven-tests
133+
--fail-on-defect`, which re-checks the report's internal consistency and fails
134+
on an unsupported claim (an Actually-Proven entry with no ladder, a cited proof
135+
file that is not there, a summary that disagrees with the entries).
136+
137+
Tiers are used honestly: this repository has no proof-carrying Rust tests, so
138+
almost everything is `Provisionally-Proven` and says so. Five cells are declared
139+
**absent** with the command that would test them. A declared absence is a to-do
140+
list, not a silencer - `MutationTest` is absent because no mutation campaign
141+
runs here, which is the same `PA025 MutationGap` this tool reports about other
142+
repositories.
143+
144+
== Recon: two analysers, one baseline
145+
146+
`recon.yml` scans this repository with `panic-attack assail` and with
147+
`pons-asinorum` (pinned). They answer different questions - "what here is weak?"
148+
versus "what here is wasted, self-contradictory, or missing an escape hatch?" -
149+
and a finding that appears in both is corroborated evidence while a finding in
150+
exactly one is a prompt to look.
151+
152+
Findings do **not** fail the job. This is the scanner: a scan of its own source
153+
will report what it is built to report, and failing on that creates pressure to
154+
suppress. Instead `recon/baseline.json` freezes the standing counts; a *new*
155+
category or a count *above* baseline fails; a count *below* is reported as debt
156+
paid and should be tightened in the same PR. See
157+
`docs/RECON-AND-EVIDENCE.adoc`.
158+
159+
== Squabbling: fixing stuck gates without bypassing them
160+
161+
`cicd-squabbler` (pinned to a commit) fetches this PR's live gate and reports
162+
which required contexts are missing or stuck, and maps the cross-repo CI chains
163+
this repository depends on. Its invariant is machine-checked: *green if and only
164+
if a required check actually ran and passed*, so an admin override or a dropped
165+
required context are provably not paths to green. It cannot fix code, and does
166+
not try.
167+
168+
Exit codes are part of the contract and are never swallowed:
169+
`0` ok, `2` tool failure (**fails the job**), `3` no required-status-check rule
170+
on the base branch (reported), `4` a blocking chain finding - a cycle or a dead
171+
upstream (reported; fails when `strict` is requested), `5` agent items remain.
172+
173+
== What is deliberately not here
174+
175+
* **No `paths:` filter on a check intended to be required.** A required check
176+
whose workflow filters itself off does not report as skipped - it does not
177+
report at all, and the PR waits forever on a context that cannot arrive.
178+
* **No `continue-on-error` on a gate that can disagree with nobody.** An
179+
advisory job that is always green carries no information; where a check is
180+
genuinely advisory it says so in its name and its summary.
181+
* **No suppression of a finding to make a gate green.** Where debt exists it is
182+
frozen in a baseline, in a reviewable file, with the reason.

‎scripts/ci/annotate.sh‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
4+
#
5+
# annotate.sh — run a command and turn its failures into GitHub ANNOTATIONS.
6+
#
7+
# WHY: a failed step whose only output is in a log download is a failure half the
8+
# world cannot read — offline contributors, anyone with a restricted network, and
9+
# any automation that reads the checks API (the log endpoint is a different host
10+
# from the API and is frequently unavailable). Annotations are served by the API,
11+
# so they are the durable record of *why* a job failed.
12+
#
13+
# Usage: scripts/ci/annotate.sh <label> -- <command> [args...]
14+
#
15+
# Emits at most 20 `::error::` annotations (GitHub's practical limit before they
16+
# are truncated) and always re-raises the command's exit status.
17+
set -uo pipefail
18+
19+
label="${1:?usage: annotate.sh <label> -- <command> [args...]}"
20+
shift
21+
[ "${1:-}" = "--" ] && shift
22+
23+
log="$(mktemp)"
24+
trap 'rm -f "$log"' EXIT
25+
26+
"$@" 2>&1 | tee "$log"
27+
status="${PIPESTATUS[0]}"
28+
29+
if [ "$status" -ne 0 ]; then
30+
echo "::error::${label} failed (exit ${status})"
31+
# Prefer lines that name a file and position or a compiler error, then fall
32+
# back to fenced output so the annotation is never empty.
33+
{
34+
grep -nE '^(error|warning)(\[[A-Za-z0-9-]+\])?:' "$log" \
35+
| head -10 \
36+
| sed "s|^|::error::${label}: |"
37+
grep -nE '^ *--> ' "$log" | head -5 | sed "s|^|::error::${label} at |"
38+
grep -nE '^Diff in ' "$log" | head -20 | sed "s|^|::error::rustfmt |"
39+
} || true
40+
echo "::group::${label}: tail of output"
41+
tail -60 "$log"
42+
echo "::endgroup::"
43+
fi
44+
45+
exit "$status"

‎src/aggregate/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ use std::path::{Path, PathBuf};
5454
///
5555
/// Reached from the CLI as `panic-attack aggregate --proven-tests <report.json>`;
5656
/// see that repository's `docs/INTEROP-PANIC-ATTACK.adoc` for the mapping this
57-
/// implements, and [`proven_tests::ProvenTestsRecord::defects`] for what it
57+
/// implements, and `ProvenTestsRecord::defects` for what it
5858
/// refuses to take on trust.
5959
pub mod proven_tests;
6060

0 commit comments

Comments
 (0)