From f8feb1c8f8f73e521cb4fffd77c604e1aece3bd5 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 4 Oct 2026 11:01:27 +0200 Subject: [PATCH 1/3] ci(#4472): docs-only PRs skip the Rust-only fat sections; required checks still report A new `change-class` job runs scripts/ci_change_class.sh once. class=docs means every path is an added/modified *.md outside .github/ that no Rust source compiles in (include_str!/include!/build.rs, resolved per file; env!/concat! literals matched by basename). Everything else, any non-PR event, and a failed decision mean the full set: every consumer tests == 'docs'. class=docs: x86-main runs only the doc-content sections (vendored-schemas, guard-tree, guard-cargo, pr-review-shadow, pr-review-sign); determinism and mac-check skip; workspace-test is unchanged. `gate` and `ci / gate` still run and print sov.gate / determinism-compare as "not-triggered: docs-only ()" by name; every other required section must still succeed. Also fixes guard_tree_job_test.sh row 5: its sed stopped matching gate.needs once that list grew past `determinism]`, so the mutant was never applied (RED on main too). Refs #4472 Agent: aprender-w4472 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 86 +++++++++++- scripts/ci_change_class.sh | 203 +++++++++++++++++++++++++++ scripts/tests/guard_tree_job_test.sh | 5 +- 3 files changed, 291 insertions(+), 3 deletions(-) create mode 100755 scripts/ci_change_class.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9893c375a2..b6426e9dfa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,9 +72,46 @@ jobs: # `workspace-test`, `ci / gate` and `gate`, each reading the fat jobs' results # with the same rule the old job of that name applied. # ========================================================================== + # #4472 CHANGE-CLASS: what kind of change is this diff? Decided ONCE, here, by + # scripts/ci_change_class.sh; x86-main, determinism and mac-check scale down on + # class=docs, and the verdict jobs read the same output, so a section is absent + # only because this job said docs, and `gate`/`ci / gate` say so by name. + # class=docs: every path is an added/modified *.md outside .github/ that no Rust + # source compiles in (include_str!/include!/build.rs). Anything else, any other + # event, and a FAILED decision all mean the full set: every consumer tests + # `== 'docs'`, never `!= 'full'`. What still runs for docs is every check that + # reads doc content: guard-tree, guard-cargo, vendored-schemas, both pr-review + # sections, and the workspace-test shards (their tier still runs the tree readers). + change-class: + runs-on: [self-hosted, Linux, clean-room] # arch-neutral: bash + git only + timeout-minutes: 10 + outputs: + class: ${{ steps.decide.outputs.class }} + reason: ${{ steps.decide.outputs.reason }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: change-class case table must still turn RED + run: bash scripts/ci_change_class.sh --self-test + - name: Classify the diff + id: decide + env: + EVT: ${{ github.event_name }} + MG_BASE: ${{ github.event.merge_group.base_sha }} + run: | + set -euo pipefail + bash scripts/ci_change_class.sh --event "$EVT" --base "$MG_BASE" > "$RUNNER_TEMP/class.txt" + cat "$RUNNER_TEMP/class.txt" + cat "$RUNNER_TEMP/class.txt" >> "$GITHUB_OUTPUT" + { echo '### change-class (#4472)'; sed 's/^/ /' "$RUNNER_TEMP/class.txt"; } >> "$GITHUB_STEP_SUMMARY" + x86-main: runs-on: [self-hosted, Linux, X64, clean-room] timeout-minutes: 240 + # #4472: !cancelled(), not success(): a failed change-class still runs the full set. + needs: [change-class] + if: ${{ !cancelled() }} # The sections carry their own permissions blocks (the sov provenance section # needs id-token + attestations); one job token must cover all of them. permissions: write-all @@ -122,14 +159,27 @@ jobs: - name: every section secret is plumbed under its full name run: setsid --wait bash scripts/check_ci_fat_secrets_plumbed.sh - name: Sections (the determinism raster returns first; the rest keep running) + if: needs.change-class.outputs.class != 'docs' run: >- python3 scripts/ci/fat_driver.py run --sections 'sov.*,guard-tree,guard-cargo,vendored-schemas,pr-review-shadow,pr-review-sign,mutants,determinism[X64],provable-ladder' --external-job workspace-test --results "$RUNNER_TEMP/fat/results.json" --background-until 'determinism[X64]' + # #4472 class=docs: only the sections that read doc content. sov.*, mutants, + # determinism[X64] and provable-ladder compile or run Rust that no Markdown in + # a docs diff can reach (the classifier refuses any .md a crate compiles in); + # gate and ci / gate report them as not-triggered, by name. + - name: Sections (class=docs -- the doc-content sections only, #4472) + if: needs.change-class.outputs.class == 'docs' + run: >- + python3 scripts/ci/fat_driver.py run + --sections 'vendored-schemas,guard-tree,guard-cargo,pr-review-shadow,pr-review-sign' + --results "$RUNNER_TEMP/fat/results.json" + --background-until 'vendored-schemas' # Published mid-run: the determinism job's compare section is waiting for it. - uses: actions/upload-artifact@v7 + if: needs.change-class.outputs.class != 'docs' with: name: determinism-X64 path: ${{ runner.temp }}/fat/artifacts/determinism-X64/ @@ -261,6 +311,10 @@ jobs: determinism: runs-on: [self-hosted, Linux, clean-room, ARM64] timeout-minutes: 120 + # #4472: a docs diff cannot move a raster; gate reports determinism-compare + # as not-triggered for it. A failed change-class still runs this. + needs: [change-class] + if: ${{ !cancelled() && needs.change-class.outputs.class != 'docs' }} outputs: results: ${{ steps.wait.outputs.results }} env: @@ -313,6 +367,9 @@ jobs: mac-check: runs-on: [self-hosted, macOS, ARM64, apple-silicon, m4, mini] timeout-minutes: 60 + # #4472: cargo check + apr-cli lib tests; a docs diff reaches neither. + needs: [change-class] + if: ${{ !cancelled() && needs.change-class.outputs.class != 'docs' }} env: CARGO_INCREMENTAL: 0 CARGO_TERM_COLOR: never @@ -598,14 +655,30 @@ jobs: name: ci / gate runs-on: [self-hosted, Linux, clean-room] timeout-minutes: 10 - needs: [x86-main] + needs: [change-class, x86-main] if: always() steps: - name: The sovereign-ci gate section's result env: RESULTS: ${{ needs.x86-main.outputs.results }} + X86_RESULT: ${{ needs.x86-main.result }} + CLASS: ${{ needs.change-class.outputs.class }} + CLASS_REASON: ${{ needs.change-class.outputs.reason }} run: | set -euo pipefail + # #4472: class=docs ran no sov.* section. not-triggered, by name; the + # doc-content sections x86-main did run must have passed. + if [ "$CLASS" = docs ]; then + echo "sov.gate: not-triggered: docs-only change ($CLASS_REASON)" + echo "x86-main (doc-content sections): $X86_RESULT" + [ "$X86_RESULT" = success ] || { echo "::error::ci / gate (docs): x86-main result is '$X86_RESULT'"; exit 1; } + for sec in guard-tree guard-cargo; do + r=$(printf '%s' "${RESULTS:-null}" | SEC="$sec" jq -r '(. // {})[env.SEC].result // "missing"') + echo "$sec: $r" + [ "$r" = success ] || { echo "::error::ci / gate (docs): $sec result is '$r'"; exit 1; } + done + exit 0 + fi r=$(printf '%s' "${RESULTS:-null}" | jq -r '(. // {})."sov.gate".result // "missing"') echo "sovereign-ci gate section: $r" [ "$r" = success ] || { echo "::error::ci / gate: section result is '$r'"; exit 1; } @@ -614,7 +687,7 @@ jobs: runs-on: [self-hosted, Linux, clean-room] # arch-neutral: intel, yoga or gx10 (#3100) timeout-minutes: 22 # the GPU sections are advisory (68-C3 / 68-D2); yoga is needed for its mutants-cuda section alone (#4621) - needs: [x86-main, workspace-test, determinism, mutants-table-scope, mutants-table, yoga] + needs: [x86-main, workspace-test, determinism, mutants-table-scope, mutants-table, yoga, change-class] if: always() steps: - name: Check required sections @@ -627,6 +700,8 @@ jobs: SCOPE: ${{ needs.mutants-table-scope.result }} IN_SCOPE: ${{ needs.mutants-table-scope.outputs.in_scope }} TABLE: ${{ needs.mutants-table.result }} + CLASS: ${{ needs.change-class.outputs.class }} + CLASS_REASON: ${{ needs.change-class.outputs.reason }} run: | set -euo pipefail res() { printf '%s' "${1:-null}" | SEC="$2" jq -r '(. // {})[env.SEC].result // "missing"'; } @@ -637,6 +712,13 @@ jobs: [ "$WT" = success ] || { echo "workspace-test failed: $WT"; exit 1; } for pair in X86:guard-tree X86:guard-cargo X86:sov.gate DET:determinism-compare; do job="${pair%%:*}" sec="${pair#*:}" + # #4472: on class=docs these two did not run, and say so by name; + # guard-tree and guard-cargo (they read doc content) did, and must pass. + case "$CLASS:$sec" in + docs:sov.gate | docs:determinism-compare) + echo "$sec: not-triggered: docs-only change ($CLASS_REASON)" + continue ;; + esac r=$(res "${!job}" "$sec") if [ "$r" != success ]; then echo "$sec failed: $r" diff --git a/scripts/ci_change_class.sh b/scripts/ci_change_class.sh new file mode 100755 index 0000000000..61b094c5cf --- /dev/null +++ b/scripts/ci_change_class.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# ci_change_class.sh — what KIND of change is this diff? (aprender#4472) +# +# WHY +# A PR that touches only Markdown paid for the whole fat CI surface: the sov.* +# matrix (test, lint, coverage, bench, security, provenance), the diff-scoped +# mutants section, both determinism rasters, the provable ladder and mac-check. +# None of those can read a Markdown file that no Rust source compiles in. This +# script decides ONCE, in the `change-class` job of ci.yml, and every job that +# scales down reads that job's output. The verdict jobs (`gate`, `ci / gate`) +# read the same output, so a section is absent only BECAUSE the classifier said +# docs, and they say so by name ("not-triggered: docs-only"), never silently. +# +# WHAT STILL RUNS FOR class=docs (the checks that read doc CONTENT) +# guard-tree and guard-cargo (README claims, claim literals, spec conformance, +# receipts, ...), vendored-schemas, pr-review-shadow, pr-review-sign, and every +# workspace-test shard (its own tier decision runs the tree-reader targets, +# readme_contract among them). Nothing here changes what those accept. +# +# THE DOCS CLASS — every rule must hold for EVERY path in the diff: +# 1. status A or M. A delete, rename, copy or type change is `full`: a removed +# file is read by somebody's test, and a rename is a delete. +# 2. the path ends in `.md`. +# 3. the path is not under .github/ (a workflow-adjacent file is CI config). +# 4. no Rust source or build script compiles it in: a "....md" literal inside +# include_str!/include_bytes!/include! (resolved against the .rs file's own +# directory), or any ".md" literal in a build.rs. A literal built with +# env!/concat! cannot be resolved here, so its BASENAME is matched instead, +# the conservative direction (it can only turn a docs diff `full`). +# Anything else is `full`. An empty or underivable diff is exit 2 (ENV). +# +# OUTPUT (KEY=VALUE on stdout, for `>> "$GITHUB_OUTPUT"`) +# class=docs | full +# reason= +# +# EXIT +# 0 a decision was reached (either polarity) +# 2 ENV — no diff derivable, or it is empty. NEVER guess. ci.yml treats a +# failed decision as `full`: every job that scales down tests == 'docs'. +# +# bash scripts/ci_change_class.sh --event pull_request # HEAD^1..HEAD (the merge ref) +# bash scripts/ci_change_class.sh --event merge_group --base SHA # SHA..HEAD +# bash scripts/ci_change_class.sh --event push # always full +# bash scripts/ci_change_class.sh --diff-from FILE [--repo-root D] # name-status lines (tests) +# bash scripts/ci_change_class.sh --self-test # the case table +set -euo pipefail + +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +EVENT="" BASE="" DIFF_FROM="" SELF_TEST=0 + +while [ $# -gt 0 ]; do + case "$1" in + --event) EVENT="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + --diff-from) DIFF_FROM="$2"; shift 2 ;; + --repo-root) REPO_ROOT="$2"; shift 2 ;; + --self-test) SELF_TEST=1; shift ;; + *) printf 'ci_change_class.sh: unknown argument: %s\n' "$1" >&2; exit 2 ;; + esac +done + +# Every repo-relative .md path a Rust file compiles in, one per line; a literal +# that cannot be resolved is printed as `?`. +included_md() { + local f lit dir + while IFS= read -r f; do + [ -f "$REPO_ROOT/$f" ] || continue + dir=$(dirname "$f") + # Up to three lines after the macro name: rustfmt splits a long include. + while IFS= read -r lit; do + case "$lit" in + RESOLVE:*) realpath -m --relative-to="$REPO_ROOT" "$REPO_ROOT/$dir/${lit#RESOLVE:}" ;; + BASENAME:*) printf '?%s\n' "$(basename "${lit#BASENAME:}")" ;; + esac + done < <(grep -A3 -E 'include(_str|_bytes)?!' "$REPO_ROOT/$f" 2> /dev/null | + awk '/^--$/ { dyn = 0; next } + /env!|concat!/ { dyn = 1 } + { while (match($0, /"[^"]*\.md"/)) { + print (dyn ? "BASENAME:" : "RESOLVE:") substr($0, RSTART + 1, RLENGTH - 2) + $0 = substr($0, RSTART + RLENGTH) } }') + done < <(git -C "$REPO_ROOT" grep -lE '\.md"' -- '*.rs' 2> /dev/null) + # A build script that names a .md reads it at build time: match by basename. + while IFS= read -r f; do + { grep -oE '"[^"]*\.md"' "$REPO_ROOT/$f" 2> /dev/null || true; } | tr -d '"' | + while IFS= read -r lit; do printf '?%s\n' "$(basename "$lit")"; done + done < <(git -C "$REPO_ROOT" ls-files 'build.rs' '*/build.rs') + return 0 +} + +derive_diff() { + case "$EVENT" in + pull_request) + # actions/checkout leaves the PR's merge commit: HEAD^1 is the base tip. + git -C "$REPO_ROOT" rev-parse --verify --quiet 'HEAD^1' > /dev/null || return 1 + git -C "$REPO_ROOT" diff --no-renames --name-status 'HEAD^1..HEAD' + ;; + merge_group) + [ -n "$BASE" ] || return 1 + git -C "$REPO_ROOT" diff --no-renames --name-status "$BASE..HEAD" + ;; + *) return 1 ;; + esac +} + +decide() { + local diff status path n=0 incl + if [ -n "$DIFF_FROM" ]; then + [ -f "$DIFF_FROM" ] || { printf 'reason=ENV: --diff-from names no file: %s\n' "$DIFF_FROM" >&2; return 2; } + diff=$(cat "$DIFF_FROM") + else + case "$EVENT" in + pull_request | merge_group) ;; + *) + printf 'class=full\nreason=event %s: only a pull_request or merge_group diff is classified\n' "${EVENT:-}" + return 0 + ;; + esac + diff=$(derive_diff) || { + printf 'reason=ENV: no diff derivable for %s (base=%s), refusing to guess\n' "$EVENT" "${BASE:-}" >&2 + return 2 + } + fi + diff=$(printf '%s\n' "$diff" | sed '/^[[:space:]]*$/d') + [ -n "$diff" ] || { printf 'reason=ENV: the diff is EMPTY: undecidable, not docs\n' >&2; return 2; } + incl=$(included_md) + while IFS=$'\t' read -r status path; do + n=$((n + 1)) + case "$status" in + A | M) ;; + *) printf 'class=full\nreason=%s %s: only an added or modified file can be docs\n' "$status" "$path"; return 0 ;; + esac + case "$path" in + .github/*) printf 'class=full\nreason=%s: under .github/ (CI config)\n' "$path"; return 0 ;; + *.md) ;; + *) printf 'class=full\nreason=%s: not a Markdown file\n' "$path"; return 0 ;; + esac + if printf '%s\n' "$incl" | grep -qxF -e "$path" -e "?$(basename "$path")"; then + printf 'class=full\nreason=%s: compiled into a Rust crate (include_str!/include!/build.rs)\n' "$path" + return 0 + fi + done <<< "$diff" + printf 'class=docs\nreason=all %s path(s) are added/modified Markdown outside .github/ that no Rust source compiles in\n' "$n" +} + +self_test() { + local td fails=0 rows=0 T=$'\t' + td=$(mktemp -d) + [ -n "$td" ] && [ -d "$td" ] || { printf 'self-test: mktemp -d gave no directory\n' >&2; return 2; } + trap 'rm -rf "${td:?}"' RETURN + git -C "$td" init -q + mkdir -p "$td/crates/x/src" "$td/crates/y" "$td/docs/audits" + printf 'pub const P: &str = include_str!("../../../docs/audits/plan.md");\n' > "$td/crates/x/src/lib.rs" + printf 'pub const S: &str = include_str!(\n "../../../docs/split.md"\n);\n' > "$td/crates/x/src/split.rs" + printf '#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/GUIDE.md"))]\npub fn g() {}\n' > "$td/crates/x/src/dyn.rs" + printf 'fn main() { let _ = std::fs::read("NOTES.md"); }\n' > "$td/crates/y/build.rs" + # A build.rs naming no .md, sorted LAST: a no-match grep must not end the scan (it once exited 1 here). + mkdir -p "$td/crates/z" && printf 'fn main() {}\n' > "$td/crates/z/build.rs" + git -C "$td" add -A + row() { # want, label, name-status lines... + local want="$1" label="$2" got rc=0 + shift 2 + printf '%s\n' "$@" > "$td/diff.txt" + rows=$((rows + 1)) + got=$(bash "$SELF" --diff-from "$td/diff.txt" --repo-root "$td" 2> /dev/null) || rc=$? + got=$(printf '%s\n' "$got" | sed -n 's/^class=//p') + [ "$rc" -eq 2 ] && got=ENV + if [ "$got" = "$want" ]; then + printf 'ok %-5s %s\n' "$got" "$label" + else + printf 'FAIL want=%s got=%s (rc %s): %s\n' "$want" "${got:-}" "$rc" "$label" + fails=$((fails + 1)) + fi + } + row docs "README + two book pages (the #4467 shape)" "M${T}README.md" "M${T}book/src/introduction.md" "A${T}book/src/getting-started/installation.md" + row docs "a doc beside, not equal to, an included one" "M${T}docs/audits/other.md" + row full "docs + one Rust file" "M${T}README.md" "M${T}crates/x/src/lib.rs" + row full "docs + Cargo.toml" "M${T}README.md" "M${T}Cargo.toml" + row full "a non-Markdown file under docs/ (yaml)" "M${T}docs/roadmaps/entries/PMAT-1.yaml" + row full "a deleted Markdown file" "D${T}docs/old.md" + row full "a renamed Markdown file (R status)" "R100${T}docs/a.md" + row full "Markdown under .github/" "M${T}.github/PULL_REQUEST_TEMPLATE.md" + row full "a Markdown file include_str!-ed by a crate" "M${T}docs/audits/plan.md" + row full "include_str! split over lines by rustfmt" "M${T}docs/split.md" + row full "env!/concat! include: basename match" "M${T}crates/x/GUIDE.md" + row full "a .md named by a build.rs" "M${T}crates/y/NOTES.md" + row ENV "an empty diff is undecidable, never docs" "" + rows=$((rows + 1)) + if bash "$SELF" --event push | grep -qx 'class=full'; then + printf 'ok full a push is never classified\n' + else + printf 'FAIL a push did not answer class=full\n' + fails=$((fails + 1)) + fi + printf '%s/%s rows\n' "$((rows - fails))" "$rows" + [ "$fails" -eq 0 ] +} + +if [ "$SELF_TEST" -eq 1 ]; then + self_test +else + decide +fi diff --git a/scripts/tests/guard_tree_job_test.sh b/scripts/tests/guard_tree_job_test.sh index 9733d1eb39..a5c5b34cf6 100755 --- a/scripts/tests/guard_tree_job_test.sh +++ b/scripts/tests/guard_tree_job_test.sh @@ -295,7 +295,10 @@ mutant_row "guard-tree dropped from x86-main --sections" "$m4" "$SECT_YML" diffe # 5. Mutant: x86-main dropped from gate.needs. # --------------------------------------------------------------------------- m5="$WORK/gate-drops-x86.yml" -sed -E 's/^( needs: \[)x86-main, ([a-z, -]*determinism\].*)$/\1\2/' "$CI_YML" > "$m5" +# Anchored on gate's own needs line (the only one naming both x86-main and +# determinism-and-more); the old `determinism\]` anchor stopped matching when +# gate.needs grew past determinism, so this row applied no mutant at all. +sed -E 's/^( needs: \[)x86-main, (.*determinism, .*)$/\1\2/' "$CI_YML" > "$m5" mutant_row "x86-main dropped from gate.needs" "$m5" "$SECT_YML" differs "$CI_YML" "$m5" # --------------------------------------------------------------------------- From 78551b070a8de56e84f00f2f3500310d3f3bc09b Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Sun, 4 Oct 2026 11:22:12 +0200 Subject: [PATCH 2/3] ci(#4472): docs only from a SUCCEEDED change-class; the verdict rule gets a mutation-tested guard `gate` and `ci / gate` now excuse sov.gate / determinism-compare only when needs.change-class.result == success AND class == docs; a docs output from a failed classifier job judges every section as before. scripts/check_ci_gate_docs_class_rule.sh extracts both verdict steps from ci.yml and executes them over an 18-row table (docs passes only with guard-tree, guard-cargo, workspace-test / x86-main green; empty, failed or full class never excuses a section). --self-test plants three wrong rules (trust the output without the job result, excuse every section on docs, ci / gate trusting x86-main alone): each turns the table RED; a workflow with no verdict steps is ENV rc=2. Wired into guard-tree-steps beside the mutants-rule checker. Adopted from the review of #4639 (its changes-job SUCCEEDED requirement and its executed gate-block checker), in #4717's own terms; no commit of #4639 is moved. Refs #4472 Agent: aprender-w4472 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 17 ++-- ci/sections.yml | 8 ++ scripts/check_ci_gate_docs_class_rule.sh | 122 +++++++++++++++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) create mode 100755 scripts/check_ci_gate_docs_class_rule.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6426e9dfa..746dc8bdc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -662,13 +662,15 @@ jobs: env: RESULTS: ${{ needs.x86-main.outputs.results }} X86_RESULT: ${{ needs.x86-main.result }} + CLASS_JOB: ${{ needs.change-class.result }} CLASS: ${{ needs.change-class.outputs.class }} CLASS_REASON: ${{ needs.change-class.outputs.reason }} run: | set -euo pipefail # #4472: class=docs ran no sov.* section. not-triggered, by name; the - # doc-content sections x86-main did run must have passed. - if [ "$CLASS" = docs ]; then + # doc-content sections x86-main did run must have passed. Executed against + # a case table by scripts/check_ci_gate_docs_class_rule.sh. + if [ "$CLASS_JOB" = success ] && [ "$CLASS" = docs ]; then echo "sov.gate: not-triggered: docs-only change ($CLASS_REASON)" echo "x86-main (doc-content sections): $X86_RESULT" [ "$X86_RESULT" = success ] || { echo "::error::ci / gate (docs): x86-main result is '$X86_RESULT'"; exit 1; } @@ -700,6 +702,7 @@ jobs: SCOPE: ${{ needs.mutants-table-scope.result }} IN_SCOPE: ${{ needs.mutants-table-scope.outputs.in_scope }} TABLE: ${{ needs.mutants-table.result }} + CLASS_JOB: ${{ needs.change-class.result }} CLASS: ${{ needs.change-class.outputs.class }} CLASS_REASON: ${{ needs.change-class.outputs.reason }} run: | @@ -712,10 +715,12 @@ jobs: [ "$WT" = success ] || { echo "workspace-test failed: $WT"; exit 1; } for pair in X86:guard-tree X86:guard-cargo X86:sov.gate DET:determinism-compare; do job="${pair%%:*}" sec="${pair#*:}" - # #4472: on class=docs these two did not run, and say so by name; - # guard-tree and guard-cargo (they read doc content) did, and must pass. - case "$CLASS:$sec" in - docs:sov.gate | docs:determinism-compare) + # #4472: on class=docs (from a change-class job that SUCCEEDED) these two + # did not run, and say so by name; guard-tree and guard-cargo (they read + # doc content) did, and must pass. scripts/check_ci_gate_docs_class_rule.sh + # executes this step against its case table. + case "$CLASS_JOB:$CLASS:$sec" in + success:docs:sov.gate | success:docs:determinism-compare) echo "$sec: not-triggered: docs-only change ($CLASS_REASON)" continue ;; esac diff --git a/ci/sections.yml b/ci/sections.yml index b7a03be9b4..fcc2f1a3c8 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -1329,6 +1329,14 @@ jobs: run: setsid --wait bash scripts/check_ci_gate_mutants_rule.sh - name: gate mutants rule can still go RED (#3676) run: setsid --wait bash scripts/check_ci_gate_mutants_rule.sh --self-test + # #4472: on class=docs the verdict jobs excuse sov.gate and determinism-compare + # by name. This executes both verdict steps from ci.yml against the case table: + # docs only from a change-class job that succeeded, and guard-tree/guard-cargo + # are never excused. Reads only ci.yml, so it runs here, on every PR. + - name: gate excuses a section only on a succeeded class=docs (#4472) + run: setsid --wait bash scripts/check_ci_gate_docs_class_rule.sh + - name: gate docs-class rule can still go RED (#4472) + run: setsid --wait bash scripts/check_ci_gate_docs_class_rule.sh --self-test # scripts/tests/guard_tree_test.sh is guard_tree.sh's OWN case table # (BSE-01, PMAT-1062) -- it proves guard_tree.sh's no-fail-fast # behaviour against a fixture, proves a fail-fast MUTANT of it goes RED diff --git a/scripts/check_ci_gate_docs_class_rule.sh b/scripts/check_ci_gate_docs_class_rule.sh new file mode 100755 index 0000000000..5f873b197f --- /dev/null +++ b/scripts/check_ci_gate_docs_class_rule.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# check_ci_gate_docs_class_rule.sh -- `gate` and `ci / gate` accept an absent +# section only on a class=docs run, and only for the sections a docs run skips +# (#4472). +# +# On class=docs (scripts/ci_change_class.sh, the `change-class` job) x86-main runs +# only the doc-content sections, and determinism does not run. The two verdict +# jobs then report sov.gate and determinism-compare as "not-triggered: docs-only". +# The rule this guard holds them to: +# * docs counts ONLY when change-class SUCCEEDED and said class=docs; an empty +# class, a failed job or class=full judges every section as before; +# * guard-tree and guard-cargo must still succeed on a docs run, and so must +# workspace-test (gate) and x86-main itself (ci / gate); +# * nothing else is ever excused. +# +# It does not re-implement the rule. It EXTRACTS the two steps' `run:` scripts +# from .github/workflows/ci.yml and executes them for every row, so it judges the +# code the verdict jobs run. A step it cannot find is ENV rc=2, never a pass. +# +# check_ci_gate_docs_class_rule.sh the case table over ci.yml +# check_ci_gate_docs_class_rule.sh --self-test planted wrong rules must go RED +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" || exit 2 +WF="${GATE_RULE_WORKFLOW:-$ROOT/.github/workflows/ci.yml}" +GATE_STEP="Check required sections" +CIGATE_STEP="The sovereign-ci gate section's result" + +# step_run -> that step's `run: |` body, dedented; empty if absent +step_run() { + awk -v want="- name: $2" ' + index($0, want) && !found { found = 1; ind = -1; next } + found && !body && /^ *run: \|/ { body = 1; next } + found && !body && /^ *- (name|uses):/ { exit } + body { + if ($0 ~ /^[[:space:]]*$/) { print ""; next } + match($0, /^ */) + if (ind < 0) ind = RLENGTH + if (RLENGTH < ind) exit + print substr($0, ind + 1) + }' "$1" +} + +DOCS_X86='{"guard-tree":{"result":"success"},"guard-cargo":{"result":"success"},"vendored-schemas":{"result":"success"}}' +GT_FAIL='{"guard-tree":{"result":"failure"},"guard-cargo":{"result":"success"}}' +GC_MISS='{"guard-tree":{"result":"success"}}' +FULL_X86='{"guard-tree":{"result":"success"},"guard-cargo":{"result":"success"},"sov.gate":{"result":"success"}}' +FULL_DET='{"determinism-compare":{"result":"success"}}' + +# run_gate