diff --git a/.github/ci/classify-changes-selftest.py b/.github/ci/classify-changes-selftest.py new file mode 100644 index 00000000..c0466606 --- /dev/null +++ b/.github/ci/classify-changes-selftest.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Offline behavior checks for classify-changes.py; no git repo or runner.""" + +import importlib.util +import io +import os +from pathlib import Path +import re +import tempfile +import unittest + +_spec = importlib.util.spec_from_file_location( + "classify_changes", Path(__file__).with_name("classify-changes.py")) +cc = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(cc) + +BEFORE = "b" * 40 +SHA = "c" * 40 + + +class FakeGit: + """Answers git calls from a table; records every call.""" + + def __init__(self, diffs=None, reachable_after=0, ancestor=True, raise_on=None): + self.diffs = diffs or {} + self.reachable_after = reachable_after # cat-file succeeds on the Nth probe (0 = never) + self.ancestor = ancestor + self.raise_on = raise_on + self.calls = [] + self.probes = 0 + + def run(self, *args): + self.calls.append(args) + if self.raise_on and args[0] == self.raise_on: + raise OSError("boom") + if args[0] == "cat-file": + self.probes += 1 + ok = self.reachable_after and self.probes >= self.reachable_after + return (0 if ok else 1), "" + if args[0] == "merge-base": + return (0 if self.ancestor else 1), "" + if args[0] == "fetch": + return 0, "" + if args[0] == "diff": + key = (args[2], args[3]) + if key not in self.diffs: + return 128, "" + return 0, "\n".join(self.diffs[key]) + "\n" + raise AssertionError(f"unexpected git call {args}") + + +def run(git, **env): + out = io.StringIO() + with tempfile.TemporaryDirectory() as d: + target = os.path.join(d, "out") + env = {"GITHUB_OUTPUT": target, **env} + cc.main(git=git, env=env, out=out) + with open(target, encoding="utf-8") as fh: + written = fh.read() + line = [l for l in out.getvalue().splitlines() if l.startswith("CLASSIFY ")] + assert len(line) == 1, out.getvalue() + return line[0], written + + +def push(git, before=BEFORE, forced="false"): + return run(git, GITHUB_EVENT_NAME="push", GITHUB_SHA=SHA, + CLASSIFY_BEFORE=before, CLASSIFY_FORCED=forced) + + +# [unit->REQ-CI-PUSH-DOCS-ONLY-THIN] +class PullRequestArm(unittest.TestCase): + def pr(self, files): + return run(FakeGit({("HEAD^1", "HEAD"): files}), GITHUB_EVENT_NAME="pull_request") + + def test_docs_only_pr_is_thin(self): + line, out = self.pr(["docs/RELEASE-RUNBOOK.md", "traceable-reqs.toml"]) + self.assertEqual(line, "CLASSIFY code=false reason=docs-only") + self.assertEqual(out, "code=false\n") + + def test_code_and_docs_site_prs_run_units(self): + self.assertIn("code=true reason=code-path:crates/x.rs", self.pr(["README.md", "crates/x.rs"])[0]) + self.assertIn("code=true reason=code-path:docs-site/src/a.md", self.pr(["docs-site/src/a.md"])[0]) + + +# [unit->REQ-CI-PUSH-DOCS-ONLY-THIN] +class PushArm(unittest.TestCase): + def test_docs_only_push_range_is_thin(self): + g = FakeGit({(BEFORE, SHA): ["docs/INFRA-REGISTER.md"]}, reachable_after=1) + line, out = push(g) + self.assertEqual(line, "CLASSIFY code=false reason=docs-only") + self.assertEqual(out, "code=false\n") + + def test_multi_commit_push_reads_the_whole_range_not_head_parent(self): + # The IR-147 false negative: the LAST commit of the lane is docs-only, + # an EARLIER one is code. HEAD^1..HEAD would say docs-only; the push + # range must say code. + g = FakeGit({ + (BEFORE, SHA): ["crates/xtask/src/bundle.rs", "docs/INFRA-REGISTER.md"], + ("HEAD^1", "HEAD"): ["docs/INFRA-REGISTER.md"], + }, reachable_after=1) + line, out = push(g) + self.assertEqual(line, "CLASSIFY code=true reason=code-path:crates/xtask/src/bundle.rs") + self.assertEqual(out, "code=true\n") + self.assertNotIn(("diff", "--name-only", "HEAD^1", "HEAD"), g.calls) + + def test_before_zero_runs_units(self): + self.assertEqual(push(FakeGit(), before="0" * 40)[0], "CLASSIFY code=true reason=before-zero") + self.assertEqual(push(FakeGit(), before="")[0], "CLASSIFY code=true reason=before-zero") + + def test_forced_push_runs_units(self): + g = FakeGit({(BEFORE, SHA): ["a.md"]}, reachable_after=1) + self.assertEqual(push(g, forced="true")[0], "CLASSIFY code=true reason=forced-push") + + def test_before_reached_only_after_deepening(self): + g = FakeGit({(BEFORE, SHA): ["a.md"]}, reachable_after=2) + self.assertEqual(push(g)[0], "CLASSIFY code=false reason=docs-only") + fetches = [c for c in g.calls if c[0] == "fetch"] + self.assertEqual(fetches, [("fetch", "--no-tags", "--deepen=50", "origin", SHA)]) + + def test_before_unreachable_after_bounded_deepening_runs_units(self): + g = FakeGit({(BEFORE, SHA): ["a.md"]}, reachable_after=0) + self.assertEqual(push(g)[0], "CLASSIFY code=true reason=before-unreachable") + fetches = [c[2] for c in g.calls if c[0] == "fetch"] + self.assertEqual(fetches, ["--deepen=50", "--deepen=500"], "deepening is bounded") + + def test_before_not_ancestor_runs_units(self): + g = FakeGit({(BEFORE, SHA): ["a.md"]}, reachable_after=1, ancestor=False) + self.assertEqual(push(g)[0], "CLASSIFY code=true reason=before-not-ancestor") + + def test_diff_failure_runs_units(self): + g = FakeGit({}, reachable_after=1) + self.assertEqual(push(g)[0], "CLASSIFY code=true reason=diff-failed") + + def test_empty_range_runs_units(self): + g = FakeGit({(BEFORE, SHA): []}, reachable_after=1) + self.assertEqual(push(g)[0], "CLASSIFY code=true reason=empty-range") + + +# [unit->REQ-CI-PUSH-DOCS-ONLY-THIN] +class DocsSetIsPositive(unittest.TestCase): + def test_docs_set(self): + for p in ["README.md", "CHANGELOG.md", "docs/INFRA-REGISTER.md", + "docs/adr/0050-golden-ci-merge-integration.md", "traceable-reqs.toml"]: + self.assertTrue(cc.is_docs_path(p), p) + + def test_everything_else_is_code(self): + for p in [".github/workflows/ci.yml", ".github/ci/classify-changes.py", + ".github/PULL_REQUEST_TEMPLATE.md", "docs-site/src/a.md", + "docs/ER-SKELETON.md", "crates/spt-daemon/tests/fixtures/enlyzeam/README.md", + "releases-repo/SYNC.md", "docs/instruments/ir21/bindeps-probe/Cargo.toml", + "Cargo.toml", "Cargo.lock", "crates/spt/src/cli.rs", "notes.txt"]: + self.assertFalse(cc.is_docs_path(p), p) + + def test_a_ci_only_push_runs_units(self): + g = FakeGit({(BEFORE, SHA): [".github/workflows/ci.yml", "docs/a.md"]}, reachable_after=1) + self.assertEqual(push(g)[0], "CLASSIFY code=true reason=code-path:.github/workflows/ci.yml") + + def test_compiled_markdown_matches_the_tree(self): + # Every .md the Rust tree compiles in must be in COMPILED_MARKDOWN, + # and nothing else may be: the list is derived, not remembered. + root = Path(__file__).resolve().parents[2] + pat = re.compile(r'include_(?:str|bytes)!\(\s*"([^"]+\.md)"') + found = set() + for rs in root.rglob("*.rs"): + parts = rs.relative_to(root).parts + if parts[0] in ("target", ".worktrees") or "target" in parts: + continue + for rel in pat.findall(rs.read_text(encoding="utf-8", errors="replace")): + found.add((rs.parent / rel).resolve().relative_to(root).as_posix()) + self.assertTrue(found, "control: the tree compiles in at least one .md today") + self.assertEqual(found, set(cc.COMPILED_MARKDOWN)) + + +# [unit->REQ-CI-PUSH-DOCS-ONLY-THIN] +class OtherEventsAndErrors(unittest.TestCase): + def test_dispatch_runs_units(self): + line, _ = run(FakeGit(), GITHUB_EVENT_NAME="workflow_dispatch") + self.assertEqual(line, "CLASSIFY code=true reason=event-workflow_dispatch") + + def test_unexpected_exception_runs_units(self): + g = FakeGit(raise_on="cat-file") + line, out = push(g) + self.assertEqual(line, "CLASSIFY code=true reason=classifier-error:OSError") + self.assertEqual(out, "code=true\n") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/.github/ci/classify-changes.py b/.github/ci/classify-changes.py new file mode 100644 index 00000000..70b19d65 --- /dev/null +++ b/.github/ci/classify-changes.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Classify a ci.yml run as code or docs-only (REQ-CI-DOCS-ONLY-THIN, IR-147). + +ONE classifier for both arms, so the pull_request rule and the push rule can +never drift apart: + +- pull_request: the files the PR merge commit changes (HEAD^1..HEAD), as before. +- push: the files the WHOLE push changed, github.event.before..github.sha. + Never HEAD^1..HEAD on a push. Under ff-only main, HEAD^1 is the previous + main only for a one-commit lane; a multi-commit lane (#254 landed five) puts + HEAD^1 inside the lane, and a classifier reading only the last commit would + skip unit on a merge whose product change sat earlier. That false negative is + worse than the wasted run IR-147 exists to remove. + +The default is code=true. Only a clean, fully resolved, non-empty, docs-only +range turns it false. Every conservative exit names its reason on a +`CLASSIFY code=... reason=...` line, the way the IR-144 helper does. +""" + +import os +import subprocess +import sys + +ZERO_SHA = "0" * 40 +# Bounded deepening along github.sha until event.before is reachable. The +# checkout is fetch-depth 2; a lane is rarely more than a handful of commits. +DEEPEN_STEPS = (50, 500) + + +# Markdown that is compiled into a binary or test is CODE, whatever its +# extension says. The selftest re-derives this set from every +# `include_str!`/`include_bytes!` of a `.md` in the tree and fails if they +# disagree, so a new compiled-in doc cannot slip through as "docs". +COMPILED_MARKDOWN = frozenset({"docs/ER-SKELETON.md"}) + + +def is_docs_path(path): + """True ONLY for paths positively known not to affect lint or unit: + the declarative traceable-reqs.toml registry, repo-root Markdown, and + Markdown under docs/ that nothing compiles in. Everything else is code, + including .github/** (a CI change decides whether units run), + docs-site/** (built and link-checked), crates/** and any path this list + does not name.""" + if path == "traceable-reqs.toml": + return True + if not path.endswith(".md") or path in COMPILED_MARKDOWN: + return False + if "/" not in path: + return True + return path.startswith("docs/") + + +def classify_files(files): + """(code, reason) for a resolved, non-empty file list.""" + code_files = [f for f in files if not is_docs_path(f)] + if code_files: + return True, "code-path:" + code_files[0] + return False, "docs-only" + + +class Git: + """The only process boundary. The selftest replaces it.""" + + def run(self, *args): + """(returncode, stdout).""" + p = subprocess.run(["git", *args], capture_output=True, text=True) + return p.returncode, p.stdout + + +def changed_files(git, base, head): + rc, out = git.run("diff", "--name-only", base, head) + if rc != 0: + return None + return [line for line in out.splitlines() if line.strip()] + + +def classify_push(git, before, sha, forced): + """(code, reason, files) for a push event.""" + if not before or before == ZERO_SHA: + return True, "before-zero", [] + if forced: + return True, "forced-push", [] + reached = False + for step in (0, *DEEPEN_STEPS): + if step: + git.run("fetch", "--no-tags", f"--deepen={step}", "origin", sha) + if git.run("cat-file", "-e", f"{before}^{{commit}}")[0] == 0: + reached = True + break + if not reached: + return True, "before-unreachable", [] + if git.run("merge-base", "--is-ancestor", before, sha)[0] != 0: + return True, "before-not-ancestor", [] + files = changed_files(git, before, sha) + if files is None: + return True, "diff-failed", [] + if not files: + return True, "empty-range", [] + code, reason = classify_files(files) + return code, reason, files + + +def classify(git, env): + """(code, reason, files) for this run's event.""" + event = env.get("GITHUB_EVENT_NAME", "") + if event == "pull_request": + files = changed_files(git, "HEAD^1", "HEAD") + if files is None: + return True, "diff-failed", [] + if not files: + return True, "empty-range", [] + code, reason = classify_files(files) + return code, reason, files + if event == "push": + return classify_push( + git, + env.get("CLASSIFY_BEFORE", ""), + env.get("GITHUB_SHA", ""), + env.get("CLASSIFY_FORCED", "false") == "true", + ) + return True, "event-" + (event or "unknown"), [] + + +def main(git=None, env=None, out=sys.stdout): + git = git or Git() + env = os.environ if env is None else env + try: + code, reason, files = classify(git, env) + except Exception as e: # any surprise runs units, loudly + code, reason, files = True, f"classifier-error:{type(e).__name__}", [] + print("changed files:", file=out) + for f in files: + print(f, file=out) + verdict = "true" if code else "false" + print(f"CLASSIFY code={verdict} reason={reason}", file=out) + target = env.get("GITHUB_OUTPUT") + if target: + with open(target, "a", encoding="utf-8") as fh: + fh.write(f"code={verdict}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47e4cb04..14e27abd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,27 +46,21 @@ jobs: echo "::error title=Insufficient free disk::RESOURCE=disk drive=$drive free_bytes=$free_bytes floor_bytes=$floor_bytes" exit 1 fi + - name: Self-test the change classifier + run: python3 .github/ci/classify-changes-selftest.py + # IR-147: ONE classifier for both arms. A push is classified over the + # WHOLE push, github.event.before..github.sha, NEVER HEAD^1..HEAD (a + # multi-commit ff lane puts HEAD^1 inside the lane). The docs set is + # named POSITIVELY; default code=true, and every conservative exit logs + # `CLASSIFY code=true reason=`. The IR-144 reuse step below is + # untouched. + # [impl->REQ-CI-PUSH-DOCS-ONLY-THIN] - id: classify shell: bash - run: | - set -euo pipefail - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "code=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - files=$(git diff --name-only HEAD^1 HEAD) - printf 'changed files:\n%s\n' "$files" - code=false - while IFS= read -r f; do - [ -z "$f" ] && continue - case "$f" in - docs-site/*) code=true ;; - *.md) ;; - traceable-reqs.toml) ;; - *) code=true ;; - esac - done <<< "$files" - echo "code=$code" >> "$GITHUB_OUTPUT" + env: + CLASSIFY_BEFORE: ${{ github.event.before }} + CLASSIFY_FORCED: ${{ github.event.forced }} + run: python3 .github/ci/classify-changes.py - name: Self-test push-main unit reuse run: python3 .github/ci/push-main-unit-reuse-selftest.py # [impl->REQ-CI-PUSH-MAIN-UNIT-REUSE] diff --git a/docs/GOLDEN-CI.md b/docs/GOLDEN-CI.md index 8310f100..107e9370 100644 --- a/docs/GOLDEN-CI.md +++ b/docs/GOLDEN-CI.md @@ -12,12 +12,20 @@ Pushing the assembled `golden/**` branch starts the full suite, including both c -Thin `ci.yml` retains its main-branch run record, lint and traceability. Its -`changes` job keeps the existing `code` classifier and separately publishes -`run-unit`, defaulting to `true`. Only a `push` to `refs/heads/main` may set it -to `false`, through `.github/ci/push-main-unit-reuse.py`. PR classification and -manual dispatch are unchanged; this is not a release-name or golden-green -exemption. +Thin `ci.yml` retains its main-branch run record and traceability. Its +`changes` job publishes TWO outputs, and a push to main skips unit through +exactly one of two mechanisms: + +- `code` (IR-147, [below](#push-main-docs-only-classification)): a docs-only + push range sets it to `false`, which skips lint AND unit and needs no PR + proof at all. +- `run-unit` (IR-144, this section): defaults to `true`. Only a `push` to + `refs/heads/main` whose `code` is `true` may set it to `false`, through + `.github/ci/push-main-unit-reuse.py` and exact-SHA PR proof. Lint still + runs. + +Manual dispatch is unchanged. Neither mechanism is a release-name or +golden-green exemption. The helper uses `gh api` with the changes job's repository-scoped `GITHUB_TOKEN` (`actions: read`, `contents: read`). It resolves `ci.yml`'s @@ -55,10 +63,40 @@ The offline behavior selftest is `python3 .github/ci/push-main-unit-reuse-selftest.py` (also a changes-job step); it exercises the production CLI logic with fixture `gh` responses. IR-144 implementation is authored for HANDED-FILE, **acceptance pending the -first real post-merge run**. A qualifying push must show the exact PR proof -in changes, both unit legs skipped, and lint/traceability still executing; -an unproved SHA must still execute both unit legs. Fixture checks are not -field acceptance. +first real post-merge run**. A qualifying IR-144 push is a CODE push (`code` +true). It must show the exact PR proof in changes, both unit legs skipped, +and lint and traceability still executing. An unproved code SHA must still +execute both unit legs. A docs-only push (`code` false) is the OTHER class: +it carries no PR proof and skips lint too, by the IR-147 classifier, so it +is not evidence for or against IR-144. Fixture checks are not field +acceptance. + +## Push-main docs-only classification + + + +`ci.yml`'s `changes` job runs one classifier, `.github/ci/classify-changes.py`, +for both event arms (IR-147). A pull request is classified by the files its +merge commit changes. A push is classified by the files the **whole push** +changed, `github.event.before..github.sha`. It is never classified by +`HEAD^1..HEAD`: under ff-only main, a multi-commit lane puts `HEAD^1` inside +the lane, and reading only the last commit skips unit on a merge whose product +change sat earlier. Measured on #254's range: `HEAD^1..HEAD` saw only +`docs/INFRA-REGISTER.md`, while the push range held 14 crate files. The +checkout is shallow, so the classifier deepens along `github.sha` in bounded +steps until `event.before` is present. + +The docs set is named **positively**: `traceable-reqs.toml`, repo-root +Markdown, and Markdown under `docs/` that nothing compiles in. Every other +path is code, `.github/**` included. The one compiled-in doc today is +`docs/ER-SKELETON.md` (via `include_str!`); the selftest re-derives that set +from the tree and fails if the two disagree. The default is `code=true`. A zero +`before`, a forced push, an unreachable `before`, a non-ancestor, a failed diff +and an empty range each run units and log `CLASSIFY code=true reason=`. +A docs-only push skips lint and unit; traceability always runs. The IR-144 +reuse step is unchanged and only matters when the push is code. + +**What main-tip unit evidence means:** main's exact SHA carries unit evidence for its CODE: at that sha via IR-144's exact-PR proof, or inherited from the last code sha when the tip is docs-only; the full-suite authority for an exact sha is the uncancelled golden run. ## Red protocol diff --git a/docs/INFRA-REGISTER.md b/docs/INFRA-REGISTER.md index 546150b1..e84d5766 100644 --- a/docs/INFRA-REGISTER.md +++ b/docs/INFRA-REGISTER.md @@ -1160,7 +1160,7 @@ one correctly says nothing about the other. - **Size:** small. ### IR-19 — Docs-only pushes to main run full unit legs (classifier is PR-only) — intent unverified -- **Status:** RETIRED 2026-08-18 — intentional for main pushes, not a classifier defect. · **Origin:** hertz observation 2026-08-03 (register-push +- **Status:** SUPERSEDED 2026-09-24 by [[IR-147]] (doyle ruling: build fix candidate (a); hertz lane). The invariant, written here because this tension has now been re-derived three times: **main's exact SHA carries unit evidence for its CODE: at that sha via IR-144's exact-PR proof, or inherited from the last code sha when the tip is docs-only; the full-suite authority for an exact sha is the uncancelled golden run.** IR-144 already conceded "full main-tip unit evidence" for code pushes; IR-147 extends it to docs-only tips. Earlier status kept below verbatim: RETIRED 2026-08-18 — intentional for main pushes, not a classifier defect. · **Origin:** hertz observation 2026-08-03 (register-push cadence gated his rig behind repeated hfenduleam unit legs); mechanism verified by doyle at source same night. - **What/why:** `ci.yml`'s `changes` job classifies docs-only diffs ONLY for `pull_request` events @@ -7233,7 +7233,7 @@ the wrapper's harness children orphaned + running") — so IR-80 ships callers, - **New-cap completion measured (doyle, 2026-09-18):** golden #4 `35372054865` attempt 2 Windows `test` job GREEN, wall **76m28s** (18:22:34–19:39:02Z; steps: build 8.1 / Phase A 24.9 / Phase B 30.2 / doctests 1.3 / clippy 5.0 / E2E 1.4 / docs 3.0 = 74.1 min) against the new 120-min cap, box CPU 25–39 % — the same content that VOIDed at 80m28s under the old cap on golden #3. Slack is now ~44 min; candidate (b) still unbuilt. ### IR-147 — a docs-only PR skips `unit`, so its merge has no green run to reuse and pays a full Windows+Linux unit on a Markdown-only commit -- **Status:** OPEN (doyle, 2026-09-24). Origin: releases#331, ff-landing core PR #253 (docs-only, +7 lines of this file, registering IR-146) at `b4490c4f` 11:04Z. Residual gap in [[IR-144]], which built the reuse itself — not a regression of it. +- **Status:** BUILT (hertz, 2026-09-24, fix candidate (a) as ruled by doyle; lane `ci/ir147-push-docs-only`): ONE classifier, `.github/ci/classify-changes.py`, for both arms. A push diffs `github.event.before..github.sha` after a bounded deepen, NEVER `HEAD^1..HEAD`. The docs set is named positively. Conservative exits run units under a named reason. The IR-144 helper is untouched. `REQ-CI-PUSH-DOCS-ONLY-THIN`. Acceptance pending: the next docs-only push to main (IR-146 (c) is routed to be it). Filed OPEN (doyle, 2026-09-24). Origin: releases#331, ff-landing core PR #253 (docs-only, +7 lines of this file, registering IR-146) at `b4490c4f` 11:04Z. Residual gap in [[IR-144]], which built the reuse itself — not a regression of it. - **Measured.** PR run [`35990810517`](https://github.com/BigscreenVR/spt-bs-core/actions/runs/35990810517) at `b4490c4f`: `changes` code=false ⇒ **`unit` and `lint` SKIPPED**, `changes`+`traceability` pass in 7s/25s. Post-merge push run [`35990918260`](https://github.com/BigscreenVR/spt-bs-core/actions/runs/35990918260) on the identical content: `changes` job `107604522955` emits **`UNIT_REUSE run-unit=true reason=both-unit-jobs-not-successful`**, and full `unit` runs on BOTH self-hosted boxes (Windows hfenduleam + Linux kitsubito) for a 7-line Markdown change. Core PR #254's `ci` queued behind it. - **Mechanism.** Two conditions compose. (1) `ci.yml` `changes.classify` short-circuits `code=true` for every non-`pull_request` event, so a push to main never applies the docs-only filter the PR arm applies. (2) The IR-144 reuse helper is then the only thing that can still skip `unit`, and it requires the source PR's unit jobs to have **SUCCEEDED** — a SKIPPED job is not a successful one, and the helper says so by name. The result is an inversion: a docs-only PR is cheapest at PR time and **most expensive at merge**, while a product PR is the reverse. The cheaper the PR, the more its merge costs — which is the opposite of what a lane author predicts, and it is why a docs entry should ride a product lane rather than land alone until this is fixed. - **Fix candidates (not yet ruled):** (a) apply the same docs-only classifier on push events, but it must diff **`github.event.before..github.sha`, NOT `HEAD^1..HEAD`** — under ff-only main `HEAD^1` is the previous main only for a SINGLE-commit lane; a multi-commit ff push (core PR #254 lands three) has `HEAD^1` inside the lane, so an `HEAD^1` classifier reads only the last commit and would skip `unit` on a merge whose product change sat in an earlier commit — a false-negative that is far worse than the wasted run this entry is about. It also needs more than the current `fetch-depth: 2` to have `event.before` locally. Written down because the wrong version of this fix is the obvious one, and I wrote it first myself; (b) teach the reuse helper to accept a source PR whose `unit` was skipped-because-docs-only, which re-derives (a)'s classification in Python instead of YAML and leaves two copies to drift; (c) leave it and batch docs entries as riders on product lanes. (a) is the smallest and removes the inversion at its cause; (c) is what this entry itself does in the meantime. diff --git a/docs/RELEASE-RUNBOOK.md b/docs/RELEASE-RUNBOOK.md index dd0d260a..9e15a144 100644 --- a/docs/RELEASE-RUNBOOK.md +++ b/docs/RELEASE-RUNBOOK.md @@ -517,7 +517,9 @@ dated ruling, read its condition and check it still holds. Pushing `main` fires `ci.yml` at the exact golden SHA. Under a golden milestone this run is **not a separate baseline authority**: it is thin by - design—traceability, changes, lint, unit—a strict subset of the golden run + design—traceability, changes, lint, unit, where unit may be skipped when an + exact-SHA PR run already proved it (IR-144) and lint and unit are skipped for + a docs-only push (IR-147)—a strict subset of the golden run already green at this SHA, so its green adds no coverage and must not be waited on **as evidence**. Wait for it **as occupancy**: it holds the box, and step 5 is local Cargo. Measured on 2026-07-29: lint 55s, unit Linux diff --git a/traceable-reqs.toml b/traceable-reqs.toml index 724a104e..8c0a9812 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -2703,6 +2703,10 @@ title = "A unit test that stands up a REAL broker inside a lib/bin `#[cfg(test)] required_stages = ["impl", "unit"] # ACTIVATED 2026-07-20 (doyle). Rig/CI-recipe requirement: NO doc stage (the contract lives in .config/nextest.toml's own comments, which the check now enforces) and NO int stage (the enforcement IS `xtask check`, itself already a CI gate). # ── CI policy (operator-ruled 2026-07-16): thin CI on docs-only PRs. ── +# NOTE 2026-09-24 (IR-147): "thin" carries TWO senses across these entries. HERE, "Main +# pushes also run thin CI only" means ci.yml rather than golden (units still ran on every +# main push). In REQ-CI-PUSH-DOCS-ONLY-THIN, thin means lint+unit SKIPPED, and it now applies +# to docs-only main pushes too (doyle ruling). The ruled text below is kept verbatim; read both. [[requirements]] id = "REQ-CI-DOCS-ONLY-THIN" title = "CI (operator-ruled 2026-07-16, from PR #8 review; registry-only extension operator-approved 2026-07-27; superseded for main pushes by ADR-0050 on 2026-07-29): a PR whose ENTIRE diff is Markdown OUTSIDE docs-site/ and/or the declarative `traceable-reqs.toml` registry runs THIN CI — the heavy build/test jobs are skipped via a changed-files classifier job. `traceable-reqs.toml` has no Rust/product consumer; its load-bearing correctness gate is traceability. Invariants: the traceability gate ALWAYS runs (doc/registry tags and activated-stage coverage are load-bearing evidence, and a run with zero checks is unmergeable); any docs-site/ change runs the PR lint/unit/traceability lane; any other non-Markdown path runs that same lane. Main pushes also run thin CI only; the full evidence for main's exact SHA is the uncancelled golden run that produced it, and main advances only by fast-forward to that tested SHA. The classifier is plain git diff over the PR merge commit (HEAD^1..HEAD, fetch-depth 2) — no third-party changed-files action on the self-hosted runners. Skipped-required-check note: GitHub treats an if-skipped job as satisfying required status checks, and the classifier + traceability always report, so thin PRs stay mergeable." @@ -2712,6 +2716,10 @@ required_stages = ["impl"] id = "REQ-CI-PUSH-MAIN-UNIT-REUSE" title = "Push-main thin CI skips only redundant units when this repository's ci.yml has a completed successful pull_request run at the exact head SHA with both named Linux and Windows unit jobs completed successfully in one complete run attempt. Preserve the code classifier, lint and traceability; all other events, missing or partial evidence, identity mismatches and API errors run units with a named reason." required_stages = ["doc", "impl", "unit"] +[[requirements]] +id = "REQ-CI-PUSH-DOCS-ONLY-THIN" +title = "IR-147 (doyle ruling 2026-09-24, fix candidate (a)): a push to main whose WHOLE push range github.event.before..github.sha is docs-only SKIPS lint and unit (thin in the units-skipped sense, as a docs-only PR does; not merely ci.yml-rather-than-golden), through ONE classifier shared by both arms, never HEAD^1..HEAD. Invariant: main's exact SHA carries unit evidence for its CODE: at that sha via IR-144's exact-PR proof, or inherited from the last code sha when the tip is docs-only; the full-suite authority for an exact sha is the uncancelled golden run. The docs set is named positively (traceable-reqs.toml, repo-root Markdown, docs/ Markdown that nothing compiles in); everything else, .github/** included, is code. Default code=true; zero before, forced push, before unreachable after a bounded deepen, before not an ancestor, a failed diff and an empty range each run units under a named reason." +required_stages = ["doc", "impl", "unit"] # ACTIVATED #331 H4 (hertz 2026-09-24). doc = GOLDEN-CI "Push-main docs-only classification"; impl = .github/ci/classify-changes.py + ci.yml changes.classify; unit = .github/ci/classify-changes-selftest.py (fake git; the multi-commit HEAD^1 trap; positive docs set; COMPILED_MARKDOWN derived from the tree). The IR-144 helper is untouched. # IR-135: distinguish runner/checker disagreement from private-source authentication. [[requirements]] @@ -4629,6 +4637,7 @@ requirements = [ name = "docs-traceability" requirements = [ "REQ-CI-DOCS-ONLY-THIN", + "REQ-CI-PUSH-DOCS-ONLY-THIN", "REQ-CI-PUSH-MAIN-UNIT-REUSE", "REQ-CI-CHECKER-PIN-PRECHECK", "REQ-SERVING-FIXTURE-CONTROL-PLANE",