From 955afa3dc3c6c7677c5c106fccad3f8260395d57 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 15:11:27 +0200 Subject: [PATCH 1/3] feat(flow-010): affected_set.sh diff to crates, reverse-dep closure, nextest filter (E5) (Refs #5856) PMAT-Ticket: PMAT-5802 Co-Authored-By: Claude Sonnet 5.5 PMAT-Batch: BATCH-aprender-n-receipt --- .../815-affected-set-rows.cmd | 2 + ci/sections.yml | 5 + scripts/affected_set.sh | 77 +++++++++ scripts/check_affected_readers.sh | 88 ++++++++++ scripts/check_affected_set.sh | 152 ++++++++++++++++++ 5 files changed, 324 insertions(+) create mode 100644 ci/explicit-test-commands.d/815-affected-set-rows.cmd create mode 100755 scripts/affected_set.sh create mode 100755 scripts/check_affected_readers.sh create mode 100755 scripts/check_affected_set.sh diff --git a/ci/explicit-test-commands.d/815-affected-set-rows.cmd b/ci/explicit-test-commands.d/815-affected-set-rows.cmd new file mode 100644 index 0000000000..639899fb2a --- /dev/null +++ b/ci/explicit-test-commands.d/815-affected-set-rows.cmd @@ -0,0 +1,2 @@ +# FLOW-010 E5: affected-set tool rows (leaf+dependents vs cargo tree oracle, core/Cargo.lock/unknown -> ALL). +bash scripts/check_affected_set.sh diff --git a/ci/sections.yml b/ci/sections.yml index 538998bd19..0b4e9b6b9a 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -1954,6 +1954,11 @@ jobs: run: setsid --wait bash scripts/check_lockfile_current.sh - name: "lockfile guard must still turn RED (case table)" run: setsid --wait bash scripts/check_lockfile_current.sh --self-test + # FLOW-010 E5: runs cargo metadata/tree, so it lives in the cargo job, not guard-tree-steps. + - name: "Affected-set tool case table (FLOW-010 E5, RED rows and mutants)" + run: setsid --wait bash scripts/check_affected_set.sh + - name: "Affected-set readers guard (every crate that reads other crates' files is listed; RED on a new one)" + run: setsid --wait bash scripts/check_affected_readers.sh && setsid --wait bash scripts/check_affected_readers.sh --self-test # #2640: the SAME defect class as the two steps above, one level up. The # dogfood release runner existed as two 1170-line copies whose divergence # was, in every hunk, a pin for a release verifier that one copy had and diff --git a/scripts/affected_set.sh b/scripts/affected_set.sh new file mode 100755 index 0000000000..4604dbec14 --- /dev/null +++ b/scripts/affected_set.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# FLOW-010 E5: the diff -> the set of workspace packages whose tests it can change. +# +# scripts/affected_set.sh [--filter] [--diff-from FILE] [BASE [HEAD]] +# +# Prints one package per line, or the single word ALL. With --filter it prints the +# nextest filterset instead (`package(a) | package(b)`, or `all()` for ALL). +# Fail-safe: anything this tool cannot place in a package is ALL. +# crates//... the package + its reverse-dependency closure (cargo metadata) +# a CORE crate ALL (its closure is the workspace; naming it would only hide that) +# Cargo.toml of a crate ALL (a manifest edit changes features/deps, which no path can bound) +# docs/, root *.md, ci/ (sections.yml, explicit-test-commands.d/, ...), Cargo.lock, root Cargo.toml, +# rust-toolchain.toml, .cargo/, .github/, Makefile, any other path outside a crate +# ALL (tests read README.md, CLAUDE.md, ci/sections.yml and docs/ at run time) +# Any non-.rs file under crates// (README.md, *.json, *.toml, ...) is ALL: other crates' tests read them +# (aprender-core readme_contract.rs reads every crates/*/README.md; aprender-contracts reads aprender-serve/kernel-registry.json). +# An empty diff, or a diff git cannot compute (bad base: exit 128), is ALL: nothing was +# measured, so nothing may be skipped. +# git diff runs with --no-renames: a moved file is its old path AND its new path (a rename +# out of a crate would otherwise show only the destination and the source crate would be skipped). +# --diff-from reads the changed paths from FILE (tests). AFFECTED_META / AFFECTED_ROOT +# replace `cargo metadata` and the checkout root (tests; synthetic graphs). +set -euo pipefail +# CORE = ALL on touch. apr-cli is here because other crates' tests read its sources (aprender-core readme_contract.rs reads +# apr-cli/src/commands_enum.rs; spec_checklist_*, toyota_principles_tests). Reader edges are not in cargo metadata, so a +# crate whose source another crate reads at run time must be listed (aprender-quant: aprender-serve/capability.rs reads +# aprender-quant/src/ggml_type.rs). Any crates/*/build.rs is ALL: contracts/build_helper.rs reads crates/*/build.rs. +CORE="${AFFECTED_CORE:-aprender-core aprender-compute aprender-contracts apr-cli aprender-quant}" +# READERS = crates whose tests read OTHER crates' files at run time (CARGO_MANIFEST_DIR/.., crates/, ../). CORE ones are ALL on touch, but that does not put them in a non-ALL set; every non-ALL set includes them, because the touched crate may be what they read. scripts/check_affected_readers.sh +# goes RED on a reader this list (or CORE) lacks. +READERS="${AFFECTED_READERS:-apr-cli apr-format aprender-build-sha aprender-cbtop aprender-cgp aprender-ci-tools aprender-compute aprender-compute-xtask aprender-contracts aprender-contracts-cli aprender-contrastive-data aprender-core aprender-data aprender-db aprender-decide aprender-distribute aprender-explain aprender-gpu aprender-mcp aprender-orchestrate aprender-present-cli aprender-present-core aprender-present-lib aprender-present-terminal aprender-present-yaml aprender-profile aprender-qa-certify aprender-qa-cli aprender-qa-gen aprender-qa-report aprender-qa-runner aprender-quant aprender-rag aprender-registry aprender-review-experiment aprender-rollup aprender-serve aprender-shell aprender-simulate aprender-test-cli aprender-test-lib aprender-train aprender-train-distill aprender-train-inspect aprender-train-shell aprender-tsp aprender-update aprender-verify-ml aprender-viz aprender-viz-ttop}" +filter=0 from="" +while [ $# -gt 0 ]; do + case "$1" in + --filter) filter=1; shift ;; + --diff-from) from="$2"; shift 2 ;; + *) break ;; + esac +done +case "$from" in *..*) echo "affected_set: refusing a --diff-from with \"..\": $from" >&2; exit 2 ;; esac # bashrs SEC010 +if [ -n "$from" ]; then files="$(cat "$from")" +else files="$(git diff --name-only --no-renames "${1:-origin/main}...${2:-HEAD}")" || files=""; fi +emit() { # $@ = package names, or ALL + if [ "${1:-}" = ALL ]; then [ "$filter" = 1 ] && echo 'all()' || echo ALL; return; fi + if [ "$filter" = 1 ]; then printf '%s\n' "$@" | sed 's/.*/package(&)/' | paste -sd'|' | sed 's/|/ | /g'; else printf '%s\n' "$@"; fi +} +[ -n "$files" ] || { emit ALL; exit 0; } +if [ -n "${AFFECTED_META:-}" ]; then meta="$(cat "$AFFECTED_META")" +else meta="$(cargo metadata --no-deps --format-version 1 --offline 2>/dev/null || cargo metadata --no-deps --format-version 1)"; fi +root="${AFFECTED_ROOT:-$(git rev-parse --show-toplevel)}" +dirs="$(jq -r --arg root "$root/" '.packages[] | [.name, (.manifest_path | sub($root;"") | sub("/?Cargo.toml$";""))] | @tsv' <<<"$meta")" || { emit ALL; exit 0; } +[ -n "$dirs" ] || { emit ALL; exit 0; } +touched="" +while IFS= read -r f; do + case "$f" in + '') continue ;; + crates/*/build.rs) emit ALL; exit 0 ;; + crates/*.rs) ;; + crates/*) emit ALL; exit 0 ;; + esac + # longest manifest dir that prefixes the path + pkg="$(awk -F'\t' -v f="$f" 'index(f, $2 "/") == 1 && length($2) > best { best = length($2); n = $1 } END { print n }' <<<"$dirs")" + [ -n "$pkg" ] || { emit ALL; exit 0; } + case " $CORE " in *" $pkg "*) emit ALL; exit 0 ;; esac + touched="$touched"$'\n'"$pkg" +done <<<"$files" +touched="$(printf '%s\n' "$touched" | grep -v '^$' | sort -u || true)" +[ -n "$touched" ] || { emit ALL; exit 0; } # nothing placed in a package: nothing may be skipped +# The closure, then aprender-core: its falsification_spec_v10 test scans every crate's src, so ANY non-ALL set includes it (and the READERS). +res="$(jq -r --argjson t "$(printf '%s\n' "$touched" | jq -R . | jq -s .)" --arg rd "$READERS" ' + (.packages | map({name, deps: [.dependencies[].name]})) as $p + | def grow($s): ($s + [$p|.[] | select(any(.deps[]; . as $d | $s | index($d))) | .name] | unique); + (.packages | map(.name)) as $m + | (($t | until(grow(.) == .; grow(.))) + ($rd | split(" ")) | unique) | .[] | select(. as $n | $m | index($n))' <<<"$meta")" || { emit ALL; exit 0; } +[ -n "$res" ] || { emit ALL; exit 0; } +mapfile -t out <<<"$res" +emit "${out[@]}" diff --git a/scripts/check_affected_readers.sh b/scripts/check_affected_readers.sh new file mode 100755 index 0000000000..a03c64d8dc --- /dev/null +++ b/scripts/check_affected_readers.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# FLOW-010 E5: a crate whose Rust can reach OTHER crates' files at run time is a reader edge that cargo metadata +# cannot see. scripts/affected_set.sh must list it in READERS (the only list that puts a crate in a non-ALL set), +# or a change to the crate it reads would skip its tests. RED on a crate with any path-escape form and no entry. +# Over-approximates on purpose: a false positive only adds a crate to READERS (more tests run); a miss skips tests. +# Flagged in any .rs: a string holding `..`, parent(), ancestors(), current_dir, a "crates/" string or "crates", +# CARGO_WORKSPACE_DIR, workspace_path_or_skip, read_dir(...) naming MANIFEST/crates/root/workspace, format!(...) +# naming MANIFEST_DIR or crates/. Comment-only lines and files under a target/ path component are skipped. +# Usage: check_affected_readers.sh [crates-dir] | --self-test | --list +set -euo pipefail +SET="${AFFECTED_SET_SH:-scripts/affected_set.sh}" +# the exact file:line exemptions (a crate-wide exemption hides a later reader in the same crate) +ALLOW="${AFFECTED_READERS_ALLOW:-crates/aprender-bench-tokenizer/src/lib.rs:50}" # joins ../.. only for /tokenizer.json and /models/ +# crates/ dirs outside the workspace (excluded): a change there is unplaceable -> ALL, so they need no READERS entry +NONMEMBER="${AFFECTED_NONMEMBER:-facades}" +PAT='"\.\."|"[^"]*(\.\./|/\.\.)[^"]*"|\bparent[[:space:]]*\(|Path::parent\(|Component::ParentDir|show-toplevel|\.pop\(\);[^;]*\.push\(|ancestors\(\)|current_dir|"[^"]*crates/|"crates"|CARGO_WORKSPACE_DIR|workspace_path_or_skip|read_dir\([^)]*(MANIFEST|crates|root|workspace)|format!\([^)]*(MANIFEST_DIR|crates/)' +listed() { sed -n 's/^READERS="${AFFECTED_READERS:-\(.*\)}"$/\1/p' "$SET" | tr ' ' '\n' | grep -v '^$' | sort -u; } +hits() { # $1 = crates dir; prints crate names + { grep -rEn --include='*.rs' "$PAT" "$1" 2>/dev/null || true + # file-level: CARGO_MANIFEST_DIR and .pop( anywhere in one file (rustfmt may wrap the let or split pop/push across lines) + for f in $(grep -rlE --include='*.rs' 'CARGO_MANIFEST_DIR' "$1" 2>/dev/null || true); do + grep -qE '\.pop[[:space:]]*\(' "$f" && echo "$f:0:pop-with-MANIFEST_DIR" + done; } \ + | grep -vE '^[^:]*/target/' | grep -vE '^[^:]+:[0-9]+:[[:space:]]*//' \ + | awk -F: -v allow=" $ALLOW " -v nm=" $NONMEMBER " '{ k = $1 ":" $2; if (index(allow, " " k " ")) next; n = split($1, a, "/"); if (index(nm, " " a[2] " ")) next; print a[2] }' | sort -u +} +nonmember_in_workspace() { # a NONMEMBER dir that cargo metadata lists is a member: its exemption would hide a reader + local m d + m="$({ if [ -n "${AFFECTED_META_JSON:-}" ]; then cat "$AFFECTED_META_JSON"; else cargo metadata --no-deps --format-version 1 --offline 2>/dev/null; fi; } || true)" + [ -n "$m" ] || return 0 + for d in $NONMEMBER; do + if grep -qF "/crates/$d/" <<<"$m"; then echo "RED: NONMEMBER dir crates/$d is a workspace member (cargo metadata lists it): drop it from NONMEMBER"; return 1; fi + done +} +check() { + nonmember_in_workspace || return 1 + local bad="" l c; l="$(listed)" + for c in $(hits "$1"); do grep -qx "$c" <<<"$l" || bad="$bad $c"; done + if [ -n "$bad" ]; then echo "RED: crates can reach other crates' files but are not in READERS (scripts/affected_set.sh):$bad"; return 1; fi + echo "ok: every crate with a path-escape form is in READERS" +} +if [ "${1:-}" = "--list" ]; then hits crates; exit 0; fi +if [ "${1:-}" = "--self-test" ]; then + t=$(mktemp -d); trap 'rm -rf -- "${t:?}"' EXIT + SET="$(pwd)/$SET"; cd "$t" + fail=0 + fresh() { rm -rf -- "${t:?}/crates"; mkdir -p "$t/crates/$1/src"; } # one unlisted crate holding $2 + expect_red() { # name crate body + fresh "$2"; printf '%s\n' "fn f() { $3 }" >"$t/crates/$2/src/x.rs" + if (check crates >/dev/null 2>&1); then echo "FAIL self-test: not caught: $1"; fail=1; fi + } + expect_red 'join("crates")' zz-a 'let _ = root.join("crates");' + expect_red 'MANIFEST_DIR.parent().parent()' zz-b 'let _ = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent();' + expect_red 'root.join("crates/aprender-core")' zz-c 'let _ = root.join("crates/aprender-core");' + expect_red 'format! with ../../crates' zz-d 'let _ = format!("{}/../../crates/x", env!("CARGO_MANIFEST_DIR"));' + expect_red 'ancestors().nth(2)' zz-e 'let _ = here.ancestors().nth(2);' + expect_red 'read_dir("../")' zz-f 'let _ = std::fs::read_dir("../");' + expect_red 'current_dir().join("..")' zz-g 'let _ = std::env::current_dir().unwrap().join("..");' + expect_red 'relative read_to_string' zz-h 'let _ = std::fs::read_to_string("../../crates/x/src/lib.rs");' + expect_red 'MANIFEST_DIR.parent().join(apr-cli)' zz-i 'let _ = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("apr-cli");' + expect_red 'workspace_path_or_skip!' zz-j 'let _ = workspace_path_or_skip!("crates/x/..");' + expect_red 'read_dir(workspace_root)' zz-k 'let _ = std::fs::read_dir(workspace_root);' + expect_red 'CARGO_WORKSPACE_DIR' zz-l 'let _ = env!("CARGO_WORKSPACE_DIR");' + expect_red 'format! with MANIFEST_DIR' zz-m 'let _ = format!("{}/x", env!("CARGO_MANIFEST_DIR"));' + expect_red 'a *contracts*/*target* crate name' zz-contracts-target-new 'let _ = root.join("crates");' + expect_red 'a line that mentions target/contracts' zz-n 'let _ = root.join("crates"); /* target contracts docs/ */' + expect_red 'PathBuf pop then push' zz-p 'let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); p.pop(); p.push("aprender-core");' + expect_red 'Component::ParentDir' zz-q 'let _ = Component::ParentDir;' + expect_red 'git rev-parse --show-toplevel' zz-r 'let _ = Command::new("git").args(["rev-parse", "--show-toplevel"]);' + expect_red 'Path::parent(x)' zz-s 'let _ = Path::parent(&here);' + expect_red '.parent ()' zz-t 'let _ = here.parent ();' + expect_red 'a non-member dir is only exempt when named' facades-x 'let _ = here.ancestors();' + expect_red 'rustfmt-wrapped let + split pop/push' zz-u $'let mut m =\n PathBuf::from(env!("CARGO_MANIFEST_DIR"));\n m.pop();\n m.push("aprender-core");' + expect_red 'spaced .pop (' zz-w $'let mut m = PathBuf::from(env!("CARGO_MANIFEST_DIR"));\n m.pop ();' + printf '{"packages":[{"manifest_path":"/x/crates/facades/zz-evil/Cargo.toml"}]}' >"$t/meta.json"; fresh zz-v + if (AFFECTED_META_JSON="$t/meta.json" check crates >/dev/null 2>&1); then echo "FAIL self-test: a NONMEMBER dir listed by cargo metadata passed"; fail=1; fi + # the bench-tokenizer exemption is the one line, not the crate + fresh aprender-bench-tokenizer; printf 'fn f() {}\nfn g() { let _ = root.join("crates"); }\n' >"$t/crates/aprender-bench-tokenizer/src/lib.rs" + if (AFFECTED_READERS_ALLOW="crates/aprender-bench-tokenizer/src/lib.rs:1" check crates >/dev/null 2>&1); then echo "FAIL self-test: an allow entry exempted a different line"; fail=1; fi + # a listed reader, a comment-only mention and a target/ file are green + fresh aprender-serve; echo 'fn f() { let _ = root.join("crates"); }' >"$t/crates/aprender-serve/src/y.rs" + (check crates >/dev/null 2>&1) || { echo "FAIL self-test: a listed reader was flagged"; fail=1; } + fresh zz-o; echo '// root.join("crates")' >"$t/crates/zz-o/src/y.rs" + (check crates >/dev/null 2>&1) || { echo "FAIL self-test: a comment-only line was flagged"; fail=1; } + [ "$fail" -eq 0 ] && echo "ok: self-test" + exit "$fail" +fi +check "${1:-crates}" diff --git a/scripts/check_affected_set.sh b/scripts/check_affected_set.sh new file mode 100755 index 0000000000..acb4d9c799 --- /dev/null +++ b/scripts/check_affected_set.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# FLOW-010 E5 RED rows for scripts/affected_set.sh. Oracle for the closure is +# `cargo tree -i` (a different code path from the tool's cargo-metadata jq). +set -euo pipefail +# the CI container runs as root over a host-owned checkout: git would refuse it (dubious ownership) +export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0='*' +A=scripts/affected_set.sh +t=$(mktemp -d); trap 'rm -rf -- "${t:?}"' EXIT +fail=0 +row() { # name want got + if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1"; echo " want: $2" | head -c 300; echo; echo " got: $3" | head -c 300; echo; fail=1; fi +} +run() { printf '%s\n' "$@" >"$t/d"; $A --diff-from "$t/d"; } +core_list=" aprender-core aprender-compute aprender-contracts apr-cli aprender-quant " +readers="$(sed -n 's/^READERS="${AFFECTED_READERS:-\(.*\)}"$/\1/p' "$A")" +meta=$(cargo metadata --no-deps --format-version 1 --offline 2>/dev/null) +members=$(jq -r '.packages[].name' <<<"$meta" | sort) +# a READERS entry that is not a workspace package is dropped by the tool; drop it from the oracle too +readers="$(printf '%s\n' $readers | grep -Fxf <(printf '%s\n' "$members") || true)" +# a non-core crate that has dependents, and a leaf with none +withdep="" leaf="" +for p in $members; do + case "$core_list" in *" $p "*) continue ;; esac + n=$(cargo tree --offline -i "$p" --workspace -e normal,dev,build --prefix none 2>/dev/null | sed 's/ v.*//' | sort -u | grep -vc "^$p$" || true) + dir=$(jq -r --arg p "$p" '.packages[]|select(.name==$p)|.manifest_path' <<<"$meta" | sed "s|$(git rev-parse --show-toplevel)/||; s|/Cargo.toml||") + case "$dir" in crates/*) ;; *) continue ;; esac + [ -z "$withdep" ] && [ "$n" -gt 0 ] && [ "$n" -lt 10 ] && withdep="$p:$dir" + [ -z "$leaf" ] && [ "$n" -eq 0 ] && leaf="$p:$dir" + [ -n "$withdep" ] && [ -n "$leaf" ] && break +done +[ -n "$withdep" ] && [ -n "$leaf" ] || { echo "FAIL could not find a leaf and a crate with dependents"; exit 1; } +wp=${withdep%%:*}; wd=${withdep#*:}; lp=${leaf%%:*}; ld=${leaf#*:} +# every non-ALL set also carries aprender-core (its falsification_spec_v10 test scans every crate's src) +want=$( { cargo tree --offline -i "$wp" --workspace -e normal,dev,build --prefix none 2>/dev/null | sed 's/ v.*//'; printf '%s\n' $readers; } | sort -u) +lc="$(printf '%s\n' "$lp" $readers | sort -u)" +row "crate with dependents ($wp) -> itself + dependents + aprender-core" "$want" "$(run "$wd/src/lib.rs" | sort -u)" +row "leaf crate ($lp) -> itself + aprender-core" "$lc" "$(run "$ld/src/lib.rs" | sort -u)" +row "core crate -> ALL" ALL "$(run crates/aprender-core/src/lib.rs)" +row "aprender-quant source -> ALL (aprender-serve capability.rs reads aprender-quant/src/ggml_type.rs)" ALL "$(run crates/aprender-quant/src/ggml_type.rs)" +row "a crate's build.rs -> ALL (contracts build_helper.rs reads crates/*/build.rs)" ALL "$(run "$ld/build.rs")" +row "apr-cli source -> ALL (aprender-core tests read apr-cli/src/commands_enum.rs)" ALL "$(run crates/apr-cli/src/commands_enum.rs)" +row "--diff-from with .. is refused (SEC010)" "2" "$($A --diff-from ../x >/dev/null 2>&1; echo $?)" +row "Cargo.lock -> ALL" ALL "$(run Cargo.lock)" +row "unknown path -> ALL" ALL "$(run some/unknown/path.txt)" +row "a path whose dir only prefixes a crate dir (crates/aprender-train-old/) -> ALL (awk prefix needs the trailing /)" ALL "$(run crates/aprender-train-old/src/lib.rs)" +row "unknown path next to a leaf -> ALL" ALL "$(run "$ld/src/lib.rs" scripts/whatever.sh)" +row "docs file -> ALL (tests read docs/ at run time)" ALL "$(run docs/x.md)" +row "root README.md -> ALL" ALL "$(run README.md)" +row "root CLAUDE.md -> ALL" ALL "$(run CLAUDE.md)" +row "ci/sections.yml -> ALL" ALL "$(run ci/sections.yml)" +row "ci/explicit-test-commands.d fragment -> ALL" ALL "$(run ci/explicit-test-commands.d/815-affected-set-rows.cmd)" +row "a crate's own Cargo.toml -> ALL" ALL "$(run "$ld/Cargo.toml")" +row "a .md inside a leaf crate -> ALL (aprender-core reads crates/*/README.md)" ALL "$(run "$ld/README.md")" +row "a .json inside a leaf crate -> ALL (kernel-registry.json is read by aprender-contracts)" ALL "$(run "$ld/kernel-registry.json")" +row "a .rs inside a leaf crate stays scoped (itself + aprender-core)" "$lc" "$(run "$ld/src/other.rs" | sort -u)" +row "--filter names the package and aprender-core" "$(printf 'package(%s)\n' $lc | LC_ALL=C sort | paste -sd'|' | sed 's/|/ | /g')" "$($A --filter --diff-from <(echo "$ld/src/lib.rs"))" +row "--filter ALL -> all()" "all()" "$($A --filter --diff-from <(echo Cargo.lock))" +# empty diff and a bad base are ALL (nothing measured, nothing skipped) +: >"$t/empty" +row "empty diff -> ALL" ALL "$($A --diff-from "$t/empty")" +row "bad base (git exit 128) -> ALL" ALL "$($A no-such-base-ref-5802 HEAD 2>/dev/null)" +# dev-dep counting: b depends on a ONLY as a dev-dependency; touching a must reach b. +cat >"$t/meta.json" <<'JSON' +{"packages":[ + {"name":"a","manifest_path":"/r/crates/a/Cargo.toml","dependencies":[]}, + {"name":"b","manifest_path":"/r/crates/b/Cargo.toml","dependencies":[{"name":"a","kind":"dev"}]}, + {"name":"c","manifest_path":"/r/crates/c/Cargo.toml","dependencies":[]}]} +JSON +row "dev-only edge is counted: touching a reaches b" "a +b" "$(AFFECTED_META="$t/meta.json" AFFECTED_ROOT=/r run crates/a/src/lib.rs | sort -u)" +row "an unrelated crate is not reached" "c" "$(AFFECTED_META="$t/meta.json" AFFECTED_ROOT=/r run crates/c/src/lib.rs)" +# mutant: a tool that drops dev edges misses b, and the dev-dep row must see it +sed 's/\[\.dependencies\[\]\.name\]/[.dependencies[]|select(.kind!="dev")|.name]/' "$A" >"$t/mut.sh" +if cmp -s "$A" "$t/mut.sh"; then echo "FAIL mutant did not apply"; fail=1 +else + got=$(AFFECTED_META="$t/meta.json" AFFECTED_ROOT=/r bash "$t/mut.sh" --diff-from <(echo crates/a/src/lib.rs) | sort -u) + row "MUTANT: ignoring dev edges is caught by the dev-dep row" "a" "$got" +fi +# --no-renames: moving a file out of crate a into crate c touches BOTH (git's rename detection would show only c). +rt="$t/repo"; mkdir -p "$rt/crates/a/src" "$rt/crates/b" "$rt/crates/c/src"; abs="$(cd "$(dirname "$A")" && pwd)/$(basename "$A")" +sed "s|/r/|$rt/|" "$t/meta.json" >"$t/meta-rt.json" +printf 'pub fn moved() -> u32 { 1 }\n// padding so git sees a rename, not a delete+add\n// padding\n' >"$rt/crates/a/src/x.rs" +# plumbing (write-tree/commit-tree) so the scratch repo runs no commit hooks +(cd "$rt" && git init -q && git add -A && b=$(GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t git commit-tree "$(git write-tree)" -m base) && git mv crates/a/src/x.rs crates/c/src/x.rs && git update-ref HEAD "$b" && git add -A && GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t git update-ref HEAD "$(GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t git commit-tree "$(git write-tree)" -p "$b" -m move)") +mv_run() { (cd "$rt" && AFFECTED_META="$t/meta-rt.json" AFFECTED_ROOT="$rt" bash "$1" HEAD~1 HEAD | sort -u); } +row "rename a->c touches both ends (a, its dependent b, and c)" "a +b +c" "$(mv_run "$abs")" +sed 's/--name-only --no-renames/--name-only/' "$A" >"$t/mut-rn.sh" +if cmp -s "$A" "$t/mut-rn.sh"; then echo "FAIL mutant did not apply"; fail=1 +else row "MUTANT: letting git detect renames is caught by the rename row" "c" "$(mv_run "$t/mut-rn.sh")"; fi +sed '/^ crates\/\*\/build.rs)/d' "$A" >"$t/mut-build.sh" +if cmp -s "$A" "$t/mut-build.sh"; then echo "FAIL build.rs mutant did not apply"; fail=1 +else printf '%s\n' "$ld/build.rs" >"$t/db"; row "MUTANT: dropping the build.rs arm is caught by the build.rs row" "$lc" "$(bash "$t/mut-build.sh" --diff-from "$t/db" | sort -u || true)"; fi +# mutants: the skip list that used to exist (docs/*, *.md) must be caught by the ALL rows above. +sed 's#^ crates/\*.rs) ;;# docs/* | *.md) continue ;;\n crates/*.rs) ;;#' "$A" >"$t/mut-docs.sh" +if cmp -s "$A" "$t/mut-docs.sh"; then echo "FAIL docs-skip mutant did not apply"; fail=1 +else printf '%s\n%s\n' docs/x.md "$ld/src/lib.rs" >"$t/dd"; row "MUTANT: skipping docs/*.md is caught by the docs-ALL row" "$lc" "$(bash "$t/mut-docs.sh" --diff-from "$t/dd" | sort -u || true)"; fi +sed 's#^ crates/\*) emit ALL.*# crates/*) continue ;;#' "$A" >"$t/mut-toml.sh" +if cmp -s "$A" "$t/mut-toml.sh"; then echo "FAIL manifest mutant did not apply"; fail=1 +else printf '%s\n%s\n' "$ld/Cargo.toml" "$ld/src/lib.rs" >"$t/dt"; row "MUTANT: skipping non-.rs crate files is caught by the manifest row" "$lc" "$(bash "$t/mut-toml.sh" --diff-from "$t/dt" | sort -u || true)"; fi +# CORE crates: each must give ALL, and dropping each from CORE must be seen (its dependents alone are not the workspace) +for c in aprender-compute aprender-contracts aprender-quant; do + cd_=$(jq -r --arg p "$c" '.packages[]|select(.name==$p)|.manifest_path' <<<"$meta" | sed "s|$(git rev-parse --show-toplevel)/||; s|/Cargo.toml||") + row "CORE crate $c -> ALL" ALL "$(run "$cd_/src/lib.rs")" + sed "s/ $c//" "$A" >"$t/mut-core.sh" + if cmp -s "$A" "$t/mut-core.sh"; then echo "FAIL core mutant ($c) did not apply"; fail=1 + else printf '%s\n' "$cd_/src/lib.rs" >"$t/dcore"; row "MUTANT: dropping $c from CORE is caught" "not-ALL" "$([ "$(bash "$t/mut-core.sh" --diff-from "$t/dcore" | head -n1)" != ALL ] && echo not-ALL || echo ALL)"; fi +done +# a 2-hop chain (c -> b -> a) and the multi-package --filter format, on a synthetic graph +cat >"$t/chain.json" <<'JSON' +{"packages":[ + {"name":"a","manifest_path":"/r/crates/a/Cargo.toml","dependencies":[]}, + {"name":"b","manifest_path":"/r/crates/b/Cargo.toml","dependencies":[{"name":"a","kind":null}]}, + {"name":"c","manifest_path":"/r/crates/c/Cargo.toml","dependencies":[{"name":"b","kind":null}]}, + {"name":"aprender-core","manifest_path":"/r/crates/core/Cargo.toml","dependencies":[]}]} +JSON +chain() { AFFECTED_META="$t/chain.json" AFFECTED_ROOT=/r AFFECTED_CORE=none AFFECTED_READERS=aprender-core bash "$1" --diff-from <(echo crates/a/src/lib.rs) | sort -u; } +row "2-hop chain a<-b<-c: touching a reaches b and c (+ aprender-core)" "a +aprender-core +b +c" "$(chain "$A")" +sed 's/until(grow(.) == .; grow(.))/grow(.)/' "$A" >"$t/mut-hop.sh" +if cmp -s "$A" "$t/mut-hop.sh"; then echo "FAIL 1-hop mutant did not apply"; fail=1 +else row "MUTANT: a 1-hop closure is caught by the 2-hop row" "a +aprender-core +b" "$(chain "$t/mut-hop.sh")"; fi +want_f="package(a) | package(aprender-core) | package(b) | package(c)" +row "multi-package --filter is ' | ' joined" "$want_f" "$(AFFECTED_META="$t/chain.json" AFFECTED_ROOT=/r AFFECTED_CORE=none AFFECTED_READERS=aprender-core bash "$A" --filter --diff-from <(echo crates/a/src/lib.rs))" +sed "s/ | sed 's\/|\/ | \/g'//" "$A" >"$t/mut-fmt.sh" +if cmp -s "$A" "$t/mut-fmt.sh"; then echo "FAIL filter-format mutant did not apply"; fail=1 +else row "MUTANT: an unspaced multi-package filter is caught" "package(a)|package(aprender-core)|package(b)|package(c)" "$(AFFECTED_META="$t/chain.json" AFFECTED_ROOT=/r AFFECTED_CORE=none AFFECTED_READERS=aprender-core bash "$t/mut-fmt.sh" --filter --diff-from <(echo crates/a/src/lib.rs))"; fi +# an unreadable cargo metadata is ALL, not an empty answer +echo 'not json' >"$t/bad.json" +row "unreadable metadata -> ALL" ALL "$(AFFECTED_META="$t/bad.json" bash "$A" --diff-from <(echo crates/a/src/lib.rs) 2>/dev/null)" +sed 's/ || { emit ALL; exit 0; }$//' "$A" >"$t/mut-jq.sh" +if cmp -s "$A" "$t/mut-jq.sh"; then echo "FAIL jq-failure mutant did not apply"; fail=1 +else row "MUTANT: dropping the jq-failure fallback is caught" "" "$(AFFECTED_META="$t/bad.json" bash "$t/mut-jq.sh" --diff-from <(echo crates/a/src/lib.rs) 2>/dev/null || true)"; fi +# READERS: each reader is in every non-ALL set (real tree), and dropping the READERS list is caught +for r in $readers; do + case " $(run "$ld/src/lib.rs" | tr '\n' ' ')" in *" $r "*) echo "ok reader $r is in a non-ALL set" ;; *) echo "FAIL reader $r missing from a non-ALL set"; fail=1 ;; esac +done +sed 's#^READERS=.*#READERS="aprender-core"#' "$A" >"$t/mut-readers.sh" +if cmp -s "$A" "$t/mut-readers.sh"; then echo "FAIL readers mutant did not apply"; fail=1 +else row "MUTANT: an emptied READERS list is caught" "$(printf '%s\n' aprender-core "$lp" | sort -u)" "$(printf '%s\n' "$ld/src/lib.rs" >"$t/drd"; bash "$t/mut-readers.sh" --diff-from "$t/drd" | sort -u)"; fi +# the res= jq fallback ALONE: metadata with no `dependencies` key makes that jq fail -> ALL +echo '{"packages":[{"name":"a","manifest_path":"/r/crates/a/Cargo.toml"}]}' >"$t/nodeps.json" +row "no dependencies key -> ALL (res= fallback)" ALL "$(AFFECTED_META="$t/nodeps.json" AFFECTED_ROOT=/r bash "$A" --diff-from <(echo crates/a/src/lib.rs) 2>/dev/null)" +sed '/<<<"$meta")" || { emit ALL; exit 0; }$/s/ || { emit ALL; exit 0; }$//' "$A" >"$t/mut-res.sh" +if cmp -s "$A" "$t/mut-res.sh"; then echo "FAIL res-fallback mutant did not apply"; fail=1 +else row "MUTANT: dropping only the res= fallback is caught" "" "$(AFFECTED_META="$t/nodeps.json" AFFECTED_ROOT=/r bash "$t/mut-res.sh" --diff-from <(echo crates/a/src/lib.rs) 2>/dev/null || true)"; fi +exit "$fail" From bd06041e181b2bdc53809834f1d3806aaf5670fd Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 15:11:55 +0200 Subject: [PATCH 2/3] PMAT-5737: signed batch receipt can stand in for workspace-test tests on PR/merge_group; fails closed (Refs #5758) PMAT-Ticket: PMAT-5737 Co-Authored-By: Claude Sonnet 5.5 PMAT-Batch: BATCH-aprender-n-receipt --- .flow009/batcher.pub | 4 + .github/workflows/ci.yml | 16 ++ ci/sections.yml | 90 ++++++- scripts/check_receipt_trust_boundary.sh | 59 +++++ scripts/ci/manifest_id.sh | 32 +++ scripts/ci/toolchain_id.sh | 24 ++ scripts/ci_mg_reuse.sh | 4 + scripts/ci_receipt_verify.sh | 320 ++++++++++++++++++++++++ scripts/ci_test_tier.sh | 42 +++- 9 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 .flow009/batcher.pub create mode 100755 scripts/check_receipt_trust_boundary.sh create mode 100755 scripts/ci/manifest_id.sh create mode 100755 scripts/ci/toolchain_id.sh create mode 100755 scripts/ci_receipt_verify.sh diff --git a/.flow009/batcher.pub b/.flow009/batcher.pub new file mode 100644 index 0000000000..ccc1847a23 --- /dev/null +++ b/.flow009/batcher.pub @@ -0,0 +1,4 @@ +# FLOW-009 RC-2: the public half of the lambda batcher receipt key. The private half lives only on +# lambda-labs (~/.config/pmat-batcher/receipt_ed25519, infra forjar resource pmat-batcher-receipt-key) +# and never enters git. Fingerprint: SHA256:8un1DYXhrsJ0e+6UM3w8KSE9CKBpfDVSRG41yL9wS5o (ED25519). +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINna9SBgvdA0Te1LhyTa6jXbDu8SgJmiDDPhHkLmljw7 flow009-batcher diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 408fa9a820..5d222bca47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -523,6 +523,22 @@ jobs: runs-on: [self-hosted, Linux, clean-room] timeout-minutes: 5 steps: + # A receipt-reuse run (tier=reuse, PMAT-5737) runs no test step; the shards stage the tier they decided, so a + # reuse shard fails this marker and mg-reuse-wt never cites that run on the merge queue or main (R:k6-wt-ran). + - uses: actions/download-artifact@v4 + with: + pattern: sigma-shard-* + path: ${{ runner.temp }}/sigma + - name: no shard ran tier reuse + run: | + set -euo pipefail + n=0 + for f in "$RUNNER_TEMP"/sigma/sigma-shard-*/tier; do + [ -f "$f" ] || { echo "::error::a shard staged no tier file"; exit 1; } + n=$((n+1)) + [ "$(cat "$f")" != reuse ] || { echo "::error::$f is tier reuse: this run did not test the tree"; exit 1; } + done + [ "$n" -ge 1 ] || { echo "::error::no shard tier files"; exit 1; } - run: echo "this run owes and runs the test suite" workspace-test-shard: # T43b: a merge_group entry whose tree equals a green PR run's skips the shards (mg-reuse-wt); anything else runs them diff --git a/ci/sections.yml b/ci/sections.yml index 0b4e9b6b9a..3a0db64797 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -438,8 +438,47 @@ jobs: fi ;; esac + # PMAT-5737 batch receipt (FLOW-009 RC-3): the batcher puts the signed receipt in the PR BODY, in a fenced + # block `flow009.receipt/1`. It is read from the event payload -- no API call, no comment, no ref. The + # transport is untrusted, the SIGNATURE is the trust, checked against the BASE commit's .flow009/batcher.pub. + # pull_request ONLY (a merge_group has no PR body; a push and a tag never take it). Any doubt leaves + # --receipt-ok off and the PR/queue tier is decided as before: PG-3, never red. + case "${GITHUB_EVENT_NAME}" in + pull_request) + case "${GITHUB_REF}" in + refs/tags/*) printf 'receipt: tag ref -- never honoured\n' ;; + *) + # TRUST BOUNDARY (PMAT-1889): the verifier, its helpers and the pubkey come from the BASE commit + # (pull_request.base.sha), never from this PR's tree -- otherwise a PR commits its own key (or edits the + # verifier), signs its own receipt and skips the tests it is judged by. cwd stays the PR tree, so the + # toolchain and the manifest are still computed over it. Any of them absent on base (true until the PR + # that adds them merges) or the base unfetchable => no receipt, the full gate: fail closed, never red. + rhead=$(jq -r '.pull_request.head.sha // ""' "${GITHUB_EVENT_PATH}" 2>/dev/null || true) + rbase=$(jq -r '.pull_request.base.sha // ""' "${GITHUB_EVENT_PATH}" 2>/dev/null || true) + # a depth-1 merge checkout hides HEAD^1, the PR head and the base sha: fetch all with enough history for + # the merge-base. A failed fetch leaves the verifier to refuse (R-HEADMISSING) -> the full path. + if [ -n "$rhead" ] && [ -n "$rbase" ] && bash scripts/ci/fetch_p6.sh --no-tags --depth=500 origin "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}" "$rhead" "$rbase" >/dev/null 2>&1; then + export RECEIPT_BASE="origin/${GITHUB_BASE_REF}" + trusted="$RUNNER_TEMP/flow009-trusted" + rm -rf "${trusted:?}"; mkdir -p "$trusted" + tok=1 + for tf in scripts/ci_receipt_verify.sh scripts/ci/toolchain_id.sh scripts/ci/manifest_id.sh .flow009/batcher.pub; do + git show "$rbase:$tf" > "$trusted/$(basename "$tf")" 2>/dev/null || tok=0 + done + if [ "$tok" = 1 ]; then + rv=$(FLOW009_PUBKEY="$trusted/batcher.pub" bash "$trusted/ci_receipt_verify.sh" "${GITHUB_REPOSITORY}" "${GITHUB_EVENT_PATH}" "$trusted/batcher.pub" "$trusted/toolchain_id.sh" "$trusted/manifest_id.sh" 2>/dev/null | grep -v '^coverage=') || true + else + rv="RECEIPT_OK=0 reason=verifier or pubkey absent on the base commit -- full gate" + fi + else + rv="RECEIPT_OK=0 reason=base commit not fetchable -- full gate" + fi + printf 'receipt: %s\n' "$rv" + if [ "$rv" = "RECEIPT_OK=1" ]; then args+=(--receipt-ok 1); fi ;; + esac ;; + esac bash scripts/ci_test_tier.sh "${args[@]}" | tee "$RUNNER_TEMP/tier.txt" - grep -E '^(tier|crates|targets|check_workspace|reason|cite)=' "$RUNNER_TEMP/tier.txt" >> "$GITHUB_OUTPUT" + grep -E '^(tier|crates|targets|check_workspace|reason|cite|receipt)=' "$RUNNER_TEMP/tier.txt" >> "$GITHUB_OUTPUT" # #4502 row 2: lint_passes_on_real_contracts (aprender-contracts) requires the # refinement gate to RUN whenever CI is set, and that gate compares HEAD with # merge-base(HEAD, origin/main), else the origin/main tip. This section clones at @@ -1100,6 +1139,46 @@ jobs: -e CARGO_TERM_COLOR=never \ "$IMAGE" \ cargo check --workspace --all-targets --locked + # PMAT-5737 receipt reuse: a signed batch receipt covers this exact tree, so the tests are not re-run. What + # a receipt does NOT cover is that this merge result still compiles and is clean, so shard 1 runs the three + # cheap whole-workspace checks (the same docker invocation as the over-cap step above); the other shards + # print the citation. tier=reuse with receipt=ok is only ever a pull_request. + - name: "Receipt reuse: cargo check + fmt + clippy over the workspace (PMAT-5737, shard 1)" + if: steps.tier.outputs.tier == 'reuse' && steps.tier.outputs.receipt == 'ok' && matrix.shard == 1 + timeout-minutes: 30 + env: + REASON: ${{ steps.tier.outputs.reason }} + CITE: ${{ steps.tier.outputs.cite }} + run: | + set -euo pipefail + printf 'workspace-test REUSED: %s\ncited: %s\n' "$REASON" "$CITE" + docker run --rm \ + -e CI -e GITHUB_ACTIONS -e GITHUB_REF -e GITHUB_SHA -e GITHUB_REPOSITORY -e GITHUB_RUN_ID -e GITHUB_EVENT_NAME -e GITHUB_WORKFLOW \ + -v "${GITHUB_WORKSPACE}:/workspace" \ + -v "/mnt/nvme-raid0/cargo-ci/registry/${PR_OR_REF}:/usr/local/cargo/registry" \ + -v "/mnt/nvme-raid0/targets/aprender-ci/${PR_OR_REF}/run-${GITHUB_RUN_ID}:/workspace/target" \ + -v "${SCCACHE_HOST_DIR}:/sccache" \ + -w /workspace \ + -e CARGO_TARGET_DIR=/workspace/target \ + -e RUSTC_WRAPPER=/usr/local/cargo/bin/sccache \ + -e SCCACHE_DIR=/sccache \ + -e SCCACHE_CACHE_SIZE=50G \ + -e CARGO_INCREMENTAL=0 \ + -e CARGO_BUILD_JOBS=8 \ + -e CARGO_PROFILE_TEST_DEBUG=line-tables-only \ + -e CARGO_PROFILE_DEV_DEBUG=line-tables-only \ + -e CARGO_TERM_COLOR=never \ + "$IMAGE" \ + sh -euc 'cargo check --workspace --all-targets --locked + cargo fmt --all -- --check + cargo clippy --workspace --all-targets -- -D warnings' + - name: "Receipt reuse: cite only (PMAT-5737, shards 2+)" + if: steps.tier.outputs.tier == 'reuse' && steps.tier.outputs.receipt == 'ok' && matrix.shard != 1 + env: + REASON: ${{ steps.tier.outputs.reason }} + CITE: ${{ steps.tier.outputs.cite }} + run: | + printf 'workspace-test REUSED: %s\ncited: %s\n' "$REASON" "$CITE" # BSE-17 reuse: the queue ref's tree is byte-identical to a PR head whose # workspace-test already succeeded; measuring it again is waste, not # evidence. The tier step proved both facts (trees compared by hash, the @@ -1107,7 +1186,7 @@ jobs: # T36: on a push the citation is instead the merge_group run that tested # this very sha, so the label names what is cited for each event. - name: "Reuse: this tree already passed workspace-test (BSE-17 PR head / T36 merge queue)" - if: steps.tier.outputs.tier == 'reuse' + if: steps.tier.outputs.tier == 'reuse' && steps.tier.outputs.receipt != 'ok' run: | printf 'workspace-test REUSED: %s\n' "${{ steps.tier.outputs.reason }}" case "${{ github.event_name }}" in @@ -1454,6 +1533,13 @@ jobs: # behaviour rows, structure rows and every mutant (#4672, #4734). - name: coverage receipt is sha-keyed and read only by the release gate (#4672) run: setsid --wait bash scripts/check_coverage_receipt.sh + # PMAT-1889: the receipt step in the tier job must run the BASE commit's verifier and key, never the PR tree's. + # This is that rule as a static check on this very file, with a mutant per way of breaking it; it also runs the + # verifier's own case table (RC-1..RC-5, FLOW009_PUBKEY, coverage= line). + - name: the receipt step judges a PR by the base commit's verifier and key (PMAT-1889) + run: | + setsid --wait bash scripts/check_receipt_trust_boundary.sh --self-test + setsid --wait bash scripts/ci_receipt_verify.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_receipt_trust_boundary.sh b/scripts/check_receipt_trust_boundary.sh new file mode 100755 index 0000000000..4fb3040808 --- /dev/null +++ b/scripts/check_receipt_trust_boundary.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# check_receipt_trust_boundary.sh - the CI receipt step must judge a PR by the BASE commit's verifier and key (PMAT-1889). +# +# check_receipt_trust_boundary.sh [sections-file] (default ci/sections.yml) +# check_receipt_trust_boundary.sh --self-test +# +# A static check on the workflow text. The receipt step used to run scripts/ci_receipt_verify.sh and read +# .flow009/batcher.pub from the PR's OWN tree, so a PR could commit its own ed25519 key, sign its own receipt and skip +# the tests it is judged by. The step must instead `git show "$rbase:"` (rbase = pull_request.base.sha) the +# verifier, toolchain_id.sh, manifest_id.sh and .flow009/batcher.pub into a temp dir, execute THOSE copies, and hand the +# base key over as FLOW009_PUBKEY. Prints one line per failed rule (`# T-`), exit 1; exit 0 and `trust boundary ok`. +# Self-test: the real file must pass, and each mutant (tree copy, no git show, no base sha, no pubkey env) must go red. +set -uo pipefail + +check() { + local f=$1 bad=0 + [ -f "$f" ] || { echo "no such file: $f # T-NOFILE"; return 1; } + if grep -n -E 'bash[[:space:]]+(\./)?scripts/ci_receipt_verify\.sh[[:space:]]+"?\$\{?GITHUB_REPOSITORY' "$f" >/dev/null; then + echo "the step executes the PR tree's scripts/ci_receipt_verify.sh # T-TREECOPY"; bad=1 + fi + local nl; nl=$(grep -v -E '^[[:space:]]+for[[:space:]]' "$f" | grep -c -E '^[^#]*ci_receipt_verify\.sh[^#]*[[:space:]]\.flow009/batcher\.pub' || true) + if [ "$nl" != 0 ]; then + echo "the step passes the tree's .flow009/batcher.pub path # T-TREEKEY"; bad=1 + fi + grep -q -F 'git show "$rbase:$tf"' "$f" || { echo "no git show of the base sha for the trusted copies # T-NOGITSHOW"; bad=1; } + grep -q -F "jq -r '.pull_request.base.sha" "$f" || { echo "base sha is not read from pull_request.base.sha # T-NOBASE"; bad=1; } + local loop + loop=$(grep -E 'for tf in .*ci_receipt_verify\.sh' "$f" || true) + case $loop in *scripts/ci_receipt_verify.sh*scripts/ci/toolchain_id.sh*scripts/ci/manifest_id.sh*.flow009/batcher.pub*) ;; *) + echo "the base-copy loop lacks the verifier, a helper or the pubkey # T-LOOP"; bad=1 ;; esac + grep -q -F 'FLOW009_PUBKEY="$trusted/batcher.pub" bash "$trusted/ci_receipt_verify.sh"' "$f" \ + || { echo "the base verifier copy is not run with FLOW009_PUBKEY pinned to the base key # T-NOPUBENV"; bad=1; } + return $bad +} + +self_test() { + local here real td n=0 red=0 m + here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + real=$here/../ci/sections.yml + td=$(mktemp -d "${TMPDIR:-/tmp}/rtb.XXXXXX") || return 2 + trap 'rm -rf "${td:?}"' RETURN + n=$((n + 1)); if check "$real" >/dev/null; then echo "ok row $n the real ci/sections.yml passes"; else echo "FAIL row $n the real ci/sections.yml"; check "$real"; red=1; fi + mut() { # mut LABEL WANT-MARKER SED-EXPR + n=$((n + 1)); sed -e "$3" "$real" > "$td/m.yml" + if cmp -s "$real" "$td/m.yml"; then echo "FAIL row $n $1 (mutation did not apply)"; red=1; return; fi + m=$(check "$td/m.yml") + case $m in *"$2"*) echo "ok row $n MUTANT $1 -> red";; *) echo "FAIL row $n MUTANT $1 stayed green/other: $m"; red=1;; esac + } + mut "step runs the tree copy of the verifier" 'T-TREECOPY' 's|FLOW009_PUBKEY="\$trusted/batcher.pub" bash "\$trusted/ci_receipt_verify.sh" "\${GITHUB_REPOSITORY}"|bash scripts/ci_receipt_verify.sh "${GITHUB_REPOSITORY}"|' + mut "step passes the tree key path" 'T-TREEKEY' 's|"\$trusted/batcher.pub" "\$trusted/toolchain_id.sh"|.flow009/batcher.pub "$trusted/toolchain_id.sh"|' + mut "git show of the base sha removed" 'T-NOGITSHOW' 's|git show "\$rbase:\$tf"|cat "$tf"|' + mut "base sha not read from the event" 'T-NOBASE' "s|jq -r '.pull_request.base.sha|jq -r '.pull_request.head.sha|" + mut "pubkey dropped from the base-copy loop" 'T-LOOP' 's|manifest_id.sh .flow009/batcher.pub; do|manifest_id.sh; do|' + mut "FLOW009_PUBKEY not pinned" 'T-NOPUBENV' 's|FLOW009_PUBKEY="\$trusted/batcher.pub" bash|bash|' + printf '\nthis self-test ran %s checks, %s failed\n' "$n" "$red"; [ "$red" -eq 0 ] +} + +if [ "${1:-}" = "--self-test" ]; then self_test; exit $?; fi +if check "${1:-ci/sections.yml}"; then echo "trust boundary ok"; else exit 1; fi diff --git a/scripts/ci/manifest_id.sh b/scripts/ci/manifest_id.sh new file mode 100755 index 0000000000..f13449f82d --- /dev/null +++ b/scripts/ci/manifest_id.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# manifest_id.sh - one sha256 hex naming the gate definition and its test list (PMAT-5737, FLOW-009 RC-1 `manifest`). +# +# manifest_id.sh [repo-root] +# +# Prints sha256 over, byte for byte, in this order: +# for each file in GATE_FILES (fixed order): "--file \n" + its bytes (a missing file hashes as "--missing\n") +# "--tests--\n" + the TEST LIST +# GATE_FILES: Makefile (holds `gate:`), scripts/gate_touched_crates.sh, scripts/guard_tree.sh, +# scripts/ci_test_tier.sh, scripts/ci_receipt_verify.sh, scripts/ci/toolchain_id.sh, scripts/ci/manifest_id.sh. +# TEST LIST: the workspace's test targets, `cargo metadata --no-deps --locked --offline --format-version 1` +# reduced by jq to a sorted `[{name, targets:[{name, kind}]}]` -- cheap (no build) and it changes whenever a +# crate or a test/bench/example target is added, removed or renamed. It does NOT name individual #[test] fns +# (that needs a build); a change to which tests exist INSIDE a target is not caught here, only by the gate. +# Any gate-definition or test-target change invalidates every outstanding receipt, by design. +# A missing cargo/jq or a metadata failure: prints nothing on stdout, a reason on stderr, exit 1. +set -uo pipefail + +root=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)} +GATE_FILES=(Makefile scripts/gate_touched_crates.sh scripts/guard_tree.sh scripts/ci_test_tier.sh scripts/ci_receipt_verify.sh scripts/ci/toolchain_id.sh scripts/ci/manifest_id.sh) +command -v jq >/dev/null 2>&1 || { echo "manifest_id: jq missing" >&2; exit 1; } +tests=$(cd "$root" && cargo metadata --no-deps --locked --offline --format-version 1 2>/dev/null \ + | jq -S -c '[.packages[] | {name, targets: [.targets[] | {name, kind}] | sort_by(.name, (.kind | join(",")))}] | sort_by(.name)') \ + || { echo "manifest_id: cargo metadata failed" >&2; exit 1; } +[ -n "$tests" ] && [ "$tests" != null ] || { echo "manifest_id: empty test list" >&2; exit 1; } +{ + for f in "${GATE_FILES[@]}"; do + printf -- '--file %s\n' "$f" + if [ -f "$root/$f" ]; then cat "$root/$f"; else printf -- '--missing\n'; fi + done + printf -- '--tests--\n%s\n' "$tests" +} | sha256sum | cut -d' ' -f1 diff --git a/scripts/ci/toolchain_id.sh b/scripts/ci/toolchain_id.sh new file mode 100755 index 0000000000..fafdf08884 --- /dev/null +++ b/scripts/ci/toolchain_id.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# toolchain_id.sh - one sha256 hex naming the toolchain a gate ran on (PMAT-5737, FLOW-009 RC-1 `toolchain`). +# +# toolchain_id.sh [repo-root] +# +# Prints sha256 over, byte for byte, in this order: +# /rust-toolchain.toml, or nothing when absent> +# "--rustc--\n" + the full output of `rustc -vV` +# "--image--\n" + "image=none\n" +# The gate container/image digest term is the literal `image=none`: lambda's `make gate` runs +# UNCONTAINERISED. A containerised gate MUST hash its image digest here instead (e.g. `image=sha256:`), +# and a receipt signed under one image term never verifies under another. infra's script +# (fleet/receipts/receipt.sh toolchain) hashes the same three terms in the same order. +# No rustc: prints nothing on stdout, a reason on stderr, exit 1. +set -uo pipefail + +root=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)} +vv=$(rustc -vV 2>/dev/null) || { echo "toolchain_id: rustc -vV failed" >&2; exit 1; } +[ -n "$vv" ] || { echo "toolchain_id: rustc -vV printed nothing" >&2; exit 1; } +{ + if [ -f "$root/rust-toolchain.toml" ]; then cat "$root/rust-toolchain.toml"; fi + printf -- '--rustc--\n%s\n' "$vv" + printf -- '--image--\nimage=none\n' +} | sha256sum | cut -d' ' -f1 diff --git a/scripts/ci_mg_reuse.sh b/scripts/ci_mg_reuse.sh index 45f685ac24..bf6f226667 100755 --- a/scripts/ci_mg_reuse.sh +++ b/scripts/ci_mg_reuse.sh @@ -645,6 +645,7 @@ STUB mgshape() { # mgshape FILE -> one line per violation awk -v tok='(^|[^A-Za-z0-9_-])mg-reuse($|[^A-Za-z0-9_-])' -v want="if: github.event_name == 'push' && github.ref == 'refs/heads/main'" ' /^ [A-Za-z0-9_-]+:$/ { j = $1; sub(/:$/, "", j); seen[j] = 1; next } + { jbody[j] = jbody[j] "\n" $0 } /^ if:/ { jif[j] = $0; sub(/^ /, "", jif[j]) } /^ runs-on:/ { jrun[j] = $0 } /^ needs:/ || (inn && /^ /) { jneeds[j] = jneeds[j] $0 } @@ -656,6 +657,8 @@ STUB if (jif["mg-reuse"] ~ /merge_group/) print "mg-reuse if names merge_group: a queue entry could reuse" # once the receipt judge exists, the test-ran marker must be keyed on its verdict, else a receipt run reads as tested if (("receipt-judge" in seen) && (jneeds["workspace-test-ran"] !~ /receipt-judge/ || jif["workspace-test-ran"] !~ /receipt-judge\.outputs\.ok/)) print "workspace-test-ran ignores receipt-judge: a receipt-reuse run would read as tested" + # a receipt-reuse run (tier=reuse) runs no tests: the marker must read the staged shard tier and refuse reuse (#5944) + if (jbody["workspace-test-ran"] !~ /sigma-shard/ || jbody["workspace-test-ran"] !~ /!= reuse/) print "workspace-test-ran does not refuse tier reuse: a receipt-reuse run would read as tested" # the marker must prove the shards ran: it needs them and its if requires their success (else a run with skipped shards owns it) if (jneeds["workspace-test-ran"] !~ /workspace-test-shard/) print "workspace-test-ran does not need workspace-test-shard: the marker no longer proves the shards ran" if (jif["workspace-test-ran"] !~ /needs\.workspace-test-shard\.result == .success./) print "workspace-test-ran if lacks the shard success clause: skipped or failed shards would still own the marker" @@ -693,6 +696,7 @@ STUB mgmut ran-marker-needs-judge-but-if-ignores-it "workspace-test-ran ignores receipt-judge" 's#^ workspace-test-ran:$# receipt-judge:\n runs-on: x\n workspace-test-ran:\n needs: [receipt-judge]#' mgmut ran-marker-drops-shard-needs "workspace-test-ran does not need workspace-test-shard" '/^ workspace-test-ran:$/,/^ runs-on:/{/^ needs: \[workspace-test-shard\]$/d}' mgmut ran-marker-if-drops-shard-result "workspace-test-ran if lacks the shard success clause" "/^ workspace-test-ran:\$/,/^ runs-on:/{s/ \&\& needs.workspace-test-shard.result == 'success'//}" + mgmut ran-marker-ignores-reuse-tier "workspace-test-ran does not refuse tier reuse" '/^ workspace-test-ran:$/,/^ workspace-test-shard:$/{/!= reuse/d}' mgmut mg-reuse-wt-leaves-mq-lane "mg-reuse-wt is not on the mq lane" '/^ mg-reuse-wt:$/,/^ timeout-minutes:/{s/,"mq"\]/,"clean-room"]/}' # Positive control for wire itself: one job of more than 64 KiB (a pipe buffer) whose diff --git a/scripts/ci_receipt_verify.sh b/scripts/ci_receipt_verify.sh new file mode 100755 index 0000000000..0f6327106d --- /dev/null +++ b/scripts/ci_receipt_verify.sh @@ -0,0 +1,320 @@ +#!/usr/bin/env bash +# ci_receipt_verify.sh - does a signed batch receipt in the PR body cover THIS merge tree? +# (PMAT-5737, FLOW-009 receipt format flow009.receipt/1, RC-1..RC-5; infra PMAT-1889 owns the signer.) +# +# ci_receipt_verify.sh [pubkey-file] [toolchain-id-script] [manifest-id-script] +# ci_receipt_verify.sh --self-test +# +# Run from the checked-out PR MERGE commit. is $GITHUB_EVENT_PATH: the receipt is read from +# .pull_request.body (RC-3: never an API call, never a comment, never a ref) and the PR head from +# .pull_request.head.sha. Defaults: .flow009/batcher.pub (COMMITTED at this tree), scripts/ci/toolchain_id.sh, +# scripts/ci/manifest_id.sh. Prints on stdout: +# RECEIPT_OK=1 +# RECEIPT_OK=0 reason= +# On RECEIPT_OK=1 a second stdout line `coverage=` follows when that value is numeric (sovereign-ci.yml +# reads it); absent or non-numeric => no such line. FLOW009_PUBKEY (env), when set, is THE pubkey path and overrides the +# argument and the default: the caller passes the BASE commit's copy so a PR cannot sign with a key it committed itself. +# exit 0 on RECEIPT_OK=1, 1 otherwise. FAIL CLOSED (PG-3): a missing tool or file, a parse problem or any error is +# RECEIPT_OK=0 and the caller runs the full gate -- never red. Not a tag and not a push: that is the caller's +# (ci/sections.yml runs this for pull_request only; ci_test_tier.sh refuses a tag ref again). +# +# The receipt: a fenced block whose info string is exactly `flow009.receipt/1`, ONE per body, holding a JSON object +# with exactly the RC-1 keys: schema repo batch base_sha tree commits gate_results toolchain manifest steps host +# started finished sig. Signed bytes = `jq -S -c 'del(.sig)'` + LF (the BYTE-IDENTICAL canonicalisation infra's +# fleet/receipts/receipt.sh signs). sig = `ssh-keygen -Y sign -n flow009-receipt` armor, newlines escaped in JSON. +# Verified (RC-5): sig; tree == HEAD^{tree}; commits[].patch_id multiset == `git patch-id --stable` over each +# commit of merge-base($RECEIPT_BASE or HEAD^1, PR head)..PR head; each commits[].ticket ^PMAT-[0-9]+$ and in that commit's message; +# toolchain; manifest. Also required: schema, repo == , gate_results.tests == "pass", steps sha256 hex. +# base_sha and commits[].sha are informational and never compared. +# +# Every refusal is ONE line ending in a `# R-` marker. +set -uo pipefail + +PRINCIPAL=flow009-batcher +NAMESPACE=flow009-receipt +SCHEMA=flow009.receipt/1 +FENCE='```flow009.receipt/1' +CLOSE='```' +TOP_KEYS='["base_sha","batch","commits","finished","gate_results","host","manifest","repo","schema","sig","started","steps","toolchain","tree"]' + +no() { echo "RECEIPT_OK=0 reason=$*"; exit 1; } + +verify() { + local repo=${1:-} event=${2:-} pub=${3:-.flow009/batcher.pub} tcid=${4:-scripts/ci/toolchain_id.sh} manid=${5:-scripts/ci/manifest_id.sh} + local cov td fences rcpt keyline head_tree head_sha base merge_base got i n pid tk sha ok + # FLOW009_PUBKEY (trust boundary, PMAT-1889): when set it IS the pubkey path and wins over the argument and the default, + # so a caller can pin the BASE commit's key and a PR can never supply its own. Set but unreadable => refused below. + if [ -n "${FLOW009_PUBKEY:-}" ]; then pub=$FLOW009_PUBKEY; fi + [ -n "$repo" ] && [ -n "$event" ] || no "usage: " # R-USAGE + [ -f "$event" ] || no "no event file" # R-NOEVENT + command -v jq >/dev/null 2>&1 || no "jq missing" # R-NOJQ + command -v ssh-keygen >/dev/null 2>&1 || no "ssh-keygen missing" # R-NOSSHKEYGEN + command -v git >/dev/null 2>&1 || no "git missing" # R-NOGIT + td=$(mktemp -d "${TMPDIR:-/tmp}/receipt-v.XXXXXX") || no "no tempdir" # R-TMP + trap 'rm -rf "${td:?}"' EXIT + + # (a) transport: the PR body, exactly one fenced flow009.receipt/1 block + jq -e '.pull_request | type == "object"' "$event" >/dev/null 2>&1 || no "event has no pull_request" # R-NOPR + jq -r '.pull_request.body // ""' "$event" 2>/dev/null | tr -d '\r' > "$td/body" || no "cannot read PR body" # R-BODY + fences=$(grep -c -x -F -e "$FENCE" "$td/body" || true) + [ "$fences" = 1 ] || no "PR body has $fences flow009.receipt/1 fenced blocks, want exactly 1" # R-FENCE + awk -v f="$FENCE" -v c="$CLOSE" '$0 == f && !s {s=1; next} s && $0 == c {closed=1; exit} s {print} END {exit closed ? 0 : 1}' "$td/body" > "$td/receipt.json" \ + || no "receipt block is not closed" # R-UNCLOSED + [ -s "$td/receipt.json" ] || no "empty receipt block" # R-EMPTYBLOCK + rcpt=$td/receipt.json + + # (b) shape: exact keys, exact types, no unknown or missing key + jq -e . "$rcpt" >/dev/null 2>&1 || no "receipt is not JSON" # R-JSON + jq -e --argjson k "$TOP_KEYS" 'type == "object" and (keys == $k)' "$rcpt" >/dev/null 2>&1 \ + || no "receipt keys are not exactly the RC-1 set" # R-KEYS + jq -e --arg s "$SCHEMA" '.schema == $s' "$rcpt" >/dev/null 2>&1 || no "schema is not $SCHEMA" # R-SCHEMA + jq -e '[.repo,.batch,.base_sha,.tree,.toolchain,.manifest,.steps,.host,.started,.finished,.sig] | all(type == "string")' "$rcpt" >/dev/null 2>&1 \ + || no "a scalar field is not a string" # R-TYPES + jq -e '(.gate_results | type == "object" and (keys == ["coverage","tests"]))' "$rcpt" >/dev/null 2>&1 \ + || no "gate_results keys are not exactly tests, coverage" # R-GATEKEYS + jq -e '.gate_results.tests == "pass"' "$rcpt" >/dev/null 2>&1 || no "gate_results.tests is not pass" # R-TESTSPASS + jq -e '(.commits | type == "array" and length > 0 and all(type == "object" and (keys == ["patch_id","sha","ticket"]) and ([.[]] | all(type == "string"))))' "$rcpt" >/dev/null 2>&1 \ + || no "commits is not a non-empty array of exactly {ticket, patch_id, sha}" # R-COMMITKEYS + jq -e '(.tree | test("^[0-9a-f]{40}$")) and (.toolchain | test("^[0-9a-f]{64}$")) and (.manifest | test("^[0-9a-f]{64}$")) and (.steps | test("^[0-9a-f]{64}$"))' "$rcpt" >/dev/null 2>&1 \ + || no "tree/toolchain/manifest/steps is not lowercase hex of the right length" # R-HEXFORM + jq -e '.commits | all((.ticket | test("^PMAT-[0-9]+$")) and (.patch_id | test("^[0-9a-f]{40}$")))' "$rcpt" >/dev/null 2>&1 \ + || no "a commit ticket is not PMAT- or a patch_id is not 40-hex" # R-TICKETFORM + + # (c) the signature, against the COMMITTED public key (never one that travelled with the receipt) + [ -f "$pub" ] || no "no committed .flow009/batcher.pub" # R-NOPUB + keyline=$(grep -v -E '^[[:space:]]*(#|$)' "$pub" | head -n 2) + [ "$(printf '%s\n' "$keyline" | grep -c .)" = 1 ] || no "batcher.pub is a placeholder or not exactly one key line" # R-PLACEHOLDER + [[ $keyline =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/]+=*([[:space:]].*)?$ ]] || no "batcher.pub line is not an ssh-ed25519 key" # R-PUBFORM + printf '%s namespaces="%s" %s\n' "$PRINCIPAL" "$NAMESPACE" "$(printf '%s' "$keyline" | cut -d' ' -f1,2)" > "$td/allowed" + jq -r '.sig' "$rcpt" > "$td/sig" 2>/dev/null || no "cannot read sig" # R-SIGREAD + [ -s "$td/sig" ] || no "empty sig" # R-SIGEMPTY + jq -S -c 'del(.sig)' "$rcpt" > "$td/canon" 2>/dev/null || no "cannot canonicalise" # R-CANON + ssh-keygen -Y verify -f "$td/allowed" -I "$PRINCIPAL" -n "$NAMESPACE" -s "$td/sig" < "$td/canon" >/dev/null 2>&1 \ + || no "signature does not verify" # R-SIG + + # (d) the repo and the tree + [ "$(jq -r .repo "$rcpt")" = "$repo" ] || no "repo is not $repo" # R-REPO + head_tree=$(git rev-parse 'HEAD^{tree}' 2>/dev/null) || no "cannot read HEAD^{tree}" # R-HEADTREE + [ "$(jq -r .tree "$rcpt")" = "$head_tree" ] || no "tree is not HEAD's tree $head_tree" # R-TREE + + # (e) the commits: patch-id multiset and tickets, over merge-base(HEAD^1, PR head)..PR head + head_sha=$(jq -r '.pull_request.head.sha // ""' "$event") + [[ $head_sha =~ ^[0-9a-f]{40}$ ]] || no "event has no PR head sha" # R-NOHEAD + git cat-file -e "${head_sha}^{commit}" 2>/dev/null || no "PR head commit is not in this checkout" # R-HEADMISSING + base=$(git rev-parse -q --verify "${RECEIPT_BASE:-HEAD^1}" 2>/dev/null) || no "no base ref (RECEIPT_BASE, else the merge commit's first parent)" # R-NOBASE + merge_base=$(git merge-base "$base" "$head_sha" 2>/dev/null) || no "no merge-base for base and PR head" # R-MERGEBASE + git rev-list --reverse --no-merges "${merge_base}..${head_sha}" > "$td/revs" 2>/dev/null || no "cannot list PR commits" # R-REVLIST + : > "$td/gitcommits" + : > "$td/used" + while read -r got; do + [ -n "$got" ] || continue + i=$(git diff-tree -p --no-color "$got" 2>/dev/null | git patch-id --stable | cut -d' ' -f1) + [[ $i =~ ^[0-9a-f]{40}$ ]] || no "commit $got has no patch-id (empty diff?)" # R-NOPATCHID + printf '%s %s\n' "$i" "$got" >> "$td/gitcommits" + done < "$td/revs" + n=$(jq '.commits | length' "$rcpt") + [ "$(grep -c . "$td/gitcommits")" = "$n" ] || no "receipt names $n commits, the PR has $(grep -c . "$td/gitcommits")" # R-COMMITCOUNT + [ "$(jq -r '.commits[].patch_id' "$rcpt" | sort | paste -sd, -)" = "$(cut -d' ' -f1 "$td/gitcommits" | sort | paste -sd, -)" ] \ + || no "commits[].patch_id multiset differs from git patch-id --stable over the PR" # R-PATCHIDS + for ((i = 0; i < n; i++)); do + ok=0 + pid=$(jq -r ".commits[$i].patch_id" "$rcpt"); tk=$(jq -r ".commits[$i].ticket" "$rcpt") + while read -r got sha; do + [ "$got" = "$pid" ] || continue + if grep -q -x -F -e "$sha" "$td/used"; then continue; fi + if git log -1 --format=%B "$sha" | grep -q -E "(^|[^0-9A-Za-z])${tk}([^0-9]|$)"; then ok=1; echo "$sha" >> "$td/used"; break; fi + done < "$td/gitcommits" + [ "$ok" = 1 ] || no "ticket $tk is not in the message of the commit with patch_id $pid" # R-TICKET + done + + # (f) the toolchain and the manifest, as THIS checkout computes them + # The helpers run from the BASE commit's trusted dir in CI, so they must be told the PR checkout: their own + # location is not the tree the receipt was signed over (rust-toolchain.toml, the gate files, cargo metadata). + root=$(git rev-parse --show-toplevel 2>/dev/null) || no "cannot find the checkout root" # R-ROOT + [ -f "$tcid" ] || no "no toolchain-id script" # R-NOTCID + got=$(bash "$tcid" "$root" 2>/dev/null) || no "toolchain-id script failed" # R-TCRUN + [[ $got =~ ^[0-9a-f]{64}$ ]] || no "toolchain-id script printed no sha256" # R-TCOUT + [ "$(jq -r .toolchain "$rcpt")" = "$got" ] || no "toolchain differs from this CI's toolchain" # R-TOOLCHAIN + [ -f "$manid" ] || no "no manifest-id script" # R-NOMAN + got=$(bash "$manid" "$root" 2>/dev/null) || no "manifest-id script failed" # R-MANRUN + [[ $got =~ ^[0-9a-f]{64}$ ]] || no "manifest-id script printed no sha256" # R-MANOUT + [ "$(jq -r .manifest "$rcpt")" = "$got" ] || no "manifest differs from this checkout's gate definition and test list" # R-MANIFEST + echo "RECEIPT_OK=1" + # sovereign-ci.yml reads ONE `coverage=` stdout line on exit 0; absent or non-numeric => no line (still OK). + cov=$(jq -r '.gate_results.coverage // ""' "$rcpt") + if [[ $cov =~ ^[0-9]+([.][0-9]+)?$ ]]; then echo "coverage=$cov"; fi + exit 0 +} + +self_test() { + local td n=0 red=0 out rc here tc man tree head pid1 pid2 pidx + here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + td=$(mktemp -d "${TMPDIR:-/tmp}/receipt-verify.XXXXXX") || { echo "self-test: no tempdir" >&2; return 2; } + trap 'rm -rf "${td:?}"' RETURN + command -v ssh-keygen >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 || { echo "self-test: ssh-keygen or jq missing" >&2; return 2; } + # throwaway keys, born and deleted inside $td + ssh-keygen -q -t ed25519 -N '' -C t -f "$td/k" >/dev/null 2>&1 || { echo "self-test: keygen failed" >&2; return 2; } + ssh-keygen -q -t ed25519 -N '' -C t -f "$td/other" >/dev/null 2>&1 || { echo "self-test: keygen failed" >&2; return 2; } + cp "$td/k.pub" "$td/pub" + printf '# PLACEHOLDER\n' > "$td/pub.placeholder" + tc=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + man=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + printf '#!/usr/bin/env bash\nprintf "%%s\\n" %s\n' "$tc" > "$td/tcid.sh" + printf '#!/usr/bin/env bash\nprintf "%%s\\n" %s\n' "$man" > "$td/manid.sh" + printf '#!/usr/bin/env bash\nexit 1\n' > "$td/bad.sh" + # a throwaway repo shaped like a PR merge checkout: main -> merge(main, PR head); PR = 2 commits + git init -q -b main "$td/r" || { echo "self-test: git init failed" >&2; return 2; } + ( cd "$td/r" && export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t \ + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null \ + && printf 'x\n' > f && git add f && git commit -q -m base \ + && git checkout -q -b pr && printf 'a\n' > a && git add a && git commit -q -m 'PMAT-11: add a' \ + && printf 'b\n' > b && git add b && git commit -q -m 'PMAT-22: add b' \ + && git checkout -q main && printf 'm\n' > m && git add m && git commit -q -m 'main moves' \ + && git merge -q --no-ff -m merge pr ) || { echo "self-test: repo build failed" >&2; return 2; } + tree=$(git -C "$td/r" rev-parse 'HEAD^{tree}'); head=$(git -C "$td/r" rev-parse pr) + pid1=$(git -C "$td/r" diff-tree -p "pr~1" | git patch-id --stable | cut -d' ' -f1) + pid2=$(git -C "$td/r" diff-tree -p "pr" | git patch-id --stable | cut -d' ' -f1) + pidx=$(git -C "$td/r" diff-tree -p "main~1" | git patch-id --stable | cut -d' ' -f1) + jq -n --arg t "$tree" --arg tc "$tc" --arg m "$man" --arg p1 "$pid1" --arg p2 "$pid2" '{ + schema: "flow009.receipt/1", repo: "paiml/aprender", batch: "batch/aprender/1", base_sha: "0000", + tree: $t, commits: [{ticket: "PMAT-11", patch_id: $p1, sha: "x"}, {ticket: "PMAT-22", patch_id: $p2, sha: "y"}], + gate_results: {tests: "pass", coverage: "95.1"}, toolchain: $tc, manifest: $m, + steps: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + host: "lambda-labs", started: "2026-10-19T00:00:00Z", finished: "2026-10-19T00:10:00Z"}' > "$td/base.json" + # mk NAME FILTER [--key K] [--ns N] [--post FILTER] [--fences N] [--nofence]: NAME.event = an event file whose + # body carries the receipt. FILTER edits the receipt BEFORE signing, --post AFTER (the old sig stays). + mk() { + local name=$1 filt=$2 key=$td/k ns=$NAMESPACE post=. fences=1 nofence=0 f; shift 2 + while [ $# -gt 0 ]; do + case $1 in + --key) key=$2; shift 2 ;; + --ns) ns=$2; shift 2 ;; + --post) post=$2; shift 2 ;; + --fences) fences=$2; shift 2 ;; + --nofence) nofence=1; shift ;; + *) shift ;; + esac + done + jq "$filt" "$td/base.json" > "$td/$name.pre" + jq -S -c 'del(.sig)' "$td/$name.pre" > "$td/$name.canon" + rm -f "${td:?}/$name.canon.sig" + ssh-keygen -q -Y sign -f "$key" -n "$ns" "$td/$name.canon" >/dev/null 2>&1 + jq --rawfile s "$td/$name.canon.sig" '. + {sig: $s}' "$td/$name.pre" | jq "$post" > "$td/$name.json" + { printf 'Batch PR\r\n\r\n' + if [ "$nofence" = 0 ]; then + for ((f = 0; f < fences; f++)); do printf '```flow009.receipt/1\r\n'; cat "$td/$name.json"; printf '\r\n```\r\n\r\n'; done + else printf '```json\n%s\n```\n' "$(cat "$td/$name.json")"; fi; } > "$td/$name.body" + jq -n --rawfile b "$td/$name.body" --arg h "$head" '{pull_request: {body: $b, head: {sha: $h}}}' > "$td/$name.event" + } + chk() { # (env FP= sets FLOW009_PUBKEY, cov= the expected coverage line) chk WANT LABEL NAME [pub] [tcid] [manid] (runs inside the throwaway repo) -- set `why=` before a no-row: the reason must contain it + local want=$1 label=$2 name=$3 pub=${4:-$td/pub} tcs=${5:-$td/tcid.sh} mans=${6:-$td/manid.sh} + n=$((n + 1)); rc=0 + out=$(cd "$td/r" && env ${FP:+FLOW009_PUBKEY="$FP"} bash "$here/$(basename "${BASH_SOURCE[0]}")" paiml/aprender "$td/$name.event" "$pub" "$tcs" "$mans" 2>&1) || rc=$? + if { [ "$want" = ok ] && [ "$rc" = 0 ] && [ "$out" = "RECEIPT_OK=1"$'\n'"coverage=${cov:-95.1}" ]; } || { [ "$want" = no ] && [ "$rc" = 1 ] && [[ $out == "RECEIPT_OK=0 reason="*"${why:-}"* ]]; }; then + printf 'ok row %-2s %s\n' "$n" "$label" + else printf 'FAIL row %-2s (wanted %s, rc=%s, got "%s") %s\n' "$n" "$want" "$rc" "$out" "$label"; red=1; fi + } + mk good . + chk ok "green path: signed, CRLF body, 2 commits, tree, toolchain, manifest" good + mk wrongtree '.tree="0123456789abcdef0123456789abcdef01234567"' + why="HEAD's tree" chk no "MUTANT wrong tree, validly signed" wrongtree + mk swapped ".commits[0].patch_id=\"$pidx\"" + why='multiset' chk no "MUTANT a patch_id swapped for another commit's, validly signed" swapped + mk missing '.commits |= .[0:1]' + why='receipt names' chk no "MUTANT a PR commit missing from the receipt, validly signed" missing + mk extra ".commits += [{ticket:\"PMAT-33\",patch_id:\"$pidx\",sha:\"z\"}]" + why='receipt names' chk no "MUTANT an extra commit in the receipt, validly signed" extra + mk wrongticket '.commits[0].ticket="PMAT-99"' + why='is not in the message' chk no "MUTANT a ticket not in that commit's message, validly signed" wrongticket + mk badticket '.commits[0].ticket="JIRA-11"' + why='not PMAT-' chk no "MUTANT a ticket not PMAT-, validly signed" badticket + mk prefixticket '.commits[0].ticket="PMAT-1"' + why='is not in the message' chk no "MUTANT ticket PMAT-1 is only a PREFIX of PMAT-11 in the message, validly signed" prefixticket + mk wrongtc '.toolchain="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"' + why='toolchain differs' chk no "MUTANT wrong toolchain, validly signed" wrongtc + mk wrongman '.manifest="dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"' + why='manifest differs' chk no "MUTANT wrong manifest, validly signed" wrongman + mk wrongschema '.schema="flow009.receipt/2"' + why='schema is not' chk no "MUTANT wrong schema, validly signed" wrongschema + mk extrakey '.surprise=1' + why='exactly the RC-1' chk no "MUTANT an extra top-level key, validly signed" extrakey + mk missingkey 'del(.host)' + why='exactly the RC-1' chk no "MUTANT a missing top-level key (host), validly signed" missingkey + mk gatekey '.gate_results.extra="x"' + why='gate_results keys' chk no "MUTANT an unknown key inside gate_results, validly signed" gatekey + mk commitkey '.commits[0].extra="x"' + why='exactly {ticket' chk no "MUTANT an unknown key inside a commit, validly signed" commitkey + mk testsfail '.gate_results.tests="fail"' + why='not pass' chk no "MUTANT gate_results.tests=fail, validly signed" testsfail + mk wrongrepo '.repo="paiml/other"' + why='repo is not' chk no "MUTANT wrong repo, validly signed" wrongrepo + mk edited . --post '.tree="0123456789abcdef0123456789abcdef01234567"' + why='signature does not verify' chk no "MUTANT body-edited tree with the OLD sig" edited + mk editedhost . --post '.host="evil"' + why='signature does not verify' chk no "MUTANT body-edited host with the old sig (any field is covered)" editedhost + mk wrongns . --ns other-namespace + why='signature does not verify' chk no "MUTANT wrong signing namespace" wrongns + mk unknownkey . --key "$td/other" + why='signature does not verify' chk no "MUTANT signed by a key that is not the committed batcher.pub" unknownkey + why='placeholder' chk no "MUTANT placeholder batcher.pub" good "$td/pub.placeholder" + why='signature does not verify' chk no "a test-key receipt against the COMMITTED .flow009/batcher.pub" good "$here/../.flow009/batcher.pub" + mk nofence . --nofence + why='0 flow009.receipt/1' chk no "MUTANT no flow009.receipt/1 fenced block (a plain json fence)" nofence + mk twofences . --fences 2 + why='2 flow009.receipt/1' chk no "MUTANT two flow009.receipt/1 blocks" twofences + # shape/transport layers: each row isolates ONE refusal (several later checks would also catch some of them) + sed '$d' "$td/good.body" | sed '$d' | sed '$d' | sed '$d' > "$td/unclosed.body" + jq -n --rawfile b "$td/unclosed.body" --arg h "$head" '{pull_request: {body: $b, head: {sha: $h}}}' > "$td/unclosed.event" + why='not closed' chk no "MUTANT the fenced block is never closed" unclosed + jq -n --arg h "$head" '{pull_request: {body: "```flow009.receipt/1\n```\n", head: {sha: $h}}}' > "$td/emptyblock.event" + why='empty receipt block' chk no "MUTANT an empty fenced block" emptyblock + jq -n --arg h "$head" '{pull_request: {body: "```flow009.receipt/1\nnot json\n```\n", head: {sha: $h}}}' > "$td/notjson.event" + why='not JSON' chk no "MUTANT a fenced block that is not JSON" notjson + mk numtype '.batch=7' + why='not a string' chk no "MUTANT batch is a number, validly signed" numtype + mk uphex ".tree=\"$(printf '%s' "$tree" | tr a-f A-F)\"" + why='lowercase hex' chk no "MUTANT tree in upper-case hex, validly signed" uphex + why='no committed' chk no "MUTANT no committed batcher.pub file" good "$td/absent.pub" + printf 'ssh-rsa AAAAB3Nza\n' > "$td/pub.rsa" + why='not an ssh-ed25519' chk no "MUTANT batcher.pub holds a non-ed25519 key line" good "$td/pub.rsa" + jq 'del(.pull_request.head)' "$td/good.event" > "$td/nohead2.event" + why='no PR head sha' chk no "MUTANT event carries no PR head" nohead2 + printf '#!/usr/bin/env bash\nprintf "not-a-hash\\n"\n' > "$td/junk.sh" + why='printed no sha256' chk no "MUTANT toolchain-id prints junk" good "$td/pub" "$td/junk.sh" + why='printed no sha256' chk no "MUTANT manifest-id prints junk" good "$td/pub" "$td/tcid.sh" "$td/junk.sh" + why='no toolchain-id script' chk no "MUTANT toolchain-id script absent" good "$td/pub" "$td/absent.sh" + why='no manifest-id script' chk no "MUTANT manifest-id script absent" good "$td/pub" "$td/tcid.sh" "$td/absent.sh" + why='toolchain-id script failed' chk no "MUTANT toolchain-id script fails (fail closed)" good "$td/pub" "$td/bad.sh" + why='manifest-id script failed' chk no "MUTANT manifest-id script fails (fail closed)" good "$td/pub" "$td/tcid.sh" "$td/bad.sh" + # B1 (#5944): CI runs the helpers from the trusted dir, so they only see the PR tree if handed its root. These stubs + # print their sha ONLY when argv[1] is the checkout root (the throwaway repo holds file f); a verifier that omits + # the root argument gets exit 1 here, as the real helpers' wrong-root hash would mismatch. + printf '#!/usr/bin/env bash\n[ -f "${1:-/nonexistent}/f" ] || exit 1\nprintf "%%s\\n" %s\n' "$tc" > "$td/tcroot.sh" + printf '#!/usr/bin/env bash\n[ -f "${1:-/nonexistent}/f" ] || exit 1\nprintf "%%s\\n" %s\n' "$man" > "$td/manroot.sh" + chk ok "the toolchain and manifest helpers are handed the PR checkout root" good "$td/pub" "$td/tcroot.sh" "$td/manroot.sh" + why='no event file' chk no "MUTANT missing event file (fail closed)" absent + jq -n '{push: {}}' > "$td/nopr.event" + why='no pull_request' chk no "MUTANT event with no pull_request" nopr + jq --arg h "$(printf '%040d' 0)" '.pull_request.head.sha=$h' "$td/good.event" > "$td/nohead.event" + why='not in this checkout' chk no "MUTANT PR head sha not in the checkout" nohead + # trust boundary (PMAT-1889): the key comes from the caller (FLOW009_PUBKEY = the BASE commit's copy), never the PR tree + mkdir -p "$td/r/.flow009" && cp "$td/other.pub" "$td/r/.flow009/batcher.pub" + mk selfsigned . --key "$td/other" + FP=$td/pub why='signature does not verify' chk no "MUTANT the PR tree's own batcher.pub holds the attacker key that signed the receipt; FLOW009_PUBKEY pins the real key" selfsigned .flow009/batcher.pub + FP=$td/absent.pub why='no committed' chk no "MUTANT FLOW009_PUBKEY points at a missing file (never falls back to the tree copy)" selfsigned .flow009/batcher.pub + FP=$td/absent.pub why='no committed' chk no "MUTANT FLOW009_PUBKEY missing beats a valid pubkey argument" good "$td/pub" + FP=$td/pub chk ok "the real key pinned by FLOW009_PUBKEY verifies a good receipt although the tree key is the attacker's" good .flow009/batcher.pub + # coverage= line: exactly one, the receipt's value; none when absent or non-numeric (still OK) + cov=95.1 chk ok "green path prints RECEIPT_OK=1 then exactly one coverage=95.1 line" good + out=$(cd "$td/r" && bash "$here/$(basename "${BASH_SOURCE[0]}")" paiml/aprender "$td/good.event" "$td/pub" "$td/tcid.sh" "$td/manid.sh" 2>/dev/null) + n=$((n + 1)); if [ "$(printf '%s\n' "$out" | grep -c '^coverage=')" = 1 ]; then printf 'ok row %-2s exactly one ^coverage= line on stdout\n' "$n"; else printf 'FAIL row %-2s coverage= line count\n' "$n"; red=1; fi + mk cov87 '.gate_results.coverage="87.25"' + cov=87.25 chk ok "coverage=87.25 is the receipt's own value" cov87 + mk covnan '.gate_results.coverage="n/a"' + n=$((n + 1)); out=$(cd "$td/r" && bash "$here/$(basename "${BASH_SOURCE[0]}")" paiml/aprender "$td/covnan.event" "$td/pub" "$td/tcid.sh" "$td/manid.sh" 2>/dev/null); rc=$? + if [ "$rc" = 0 ] && [ "$out" = "RECEIPT_OK=1" ]; then printf 'ok row %-2s non-numeric coverage: verify OK, no coverage= line\n' "$n"; else printf 'FAIL row %-2s non-numeric coverage (rc=%s "%s")\n' "$n" "$rc" "$out"; red=1; fi + printf '\nthis self-test ran %s checks, %s failed\n' "$n" "$red"; [ "$red" -eq 0 ] +} + +if [ "${1:-}" = "--self-test" ]; then self_test; exit $?; fi +verify "$@" diff --git a/scripts/ci_test_tier.sh b/scripts/ci_test_tier.sh index d52c6e4cd2..05c345ec7a 100755 --- a/scripts/ci_test_tier.sh +++ b/scripts/ci_test_tier.sh @@ -49,6 +49,11 @@ # reason says why it was not reused. Only workspace-test is reused: # every other push job runs as before. # +# reuse pull_request ONLY, PMAT-5737 (FLOW-009 RC-3: the receipt is in the PR body, a merge_group has none): `--receipt-ok 1` (the caller's proof that +# scripts/ci_receipt_verify.sh printed RECEIPT_OK=1 for this tree) -> tier=reuse, +# cite=receipt:, receipt=ok, decided BEFORE any diff selection. Never a push +# (its mg-reuse arm is untouched) and never a tag (tier=full). Any other value, or no flag, +# falls through to the selection above, unchanged. # Output: KEY=VALUE lines — tier, crates (space list), targets (crate:--lib, # crate:--lib:module, crate:--bins or crate:--test:name; space list), # check_workspace (1, only under rule (i)), reason, cite (on reuse: the PR head @@ -112,7 +117,7 @@ set -euo pipefail # `unset` also drops the export attribute, so no child can inherit them. unset SELF_TEST FILTERSET FS_TARGETS SELF_TEST=0; FILTERSET=0 -EVENT=""; COMPARAND=""; DIFF_FROM=""; PR_HEAD=""; PR_CONCLUSION=""; MG_RUN=""; MG_SHA=""; MG_CONCLUSION=""; MG_GIVEN=0; REGISTRY="scripts/tree_reader_tests.txt"; ROOT="." +EVENT=""; COMPARAND=""; DIFF_FROM=""; PR_HEAD=""; PR_CONCLUSION=""; MG_RUN=""; MG_SHA=""; MG_CONCLUSION=""; MG_GIVEN=0; RECEIPT_OK=0; REGISTRY="scripts/tree_reader_tests.txt"; ROOT="." # The sibling scripts and the registry always come from the checkout this script # runs in (cwd = repo root, in ci.yml and in the case table alike); --repo-root # re-points only the GIT queries — HEAD, its parents, their trees — which is how @@ -129,6 +134,7 @@ while [ $# -gt 0 ]; do --mg-run) MG_RUN=$2; MG_GIVEN=1; shift 2 ;; --mg-sha) MG_SHA=$2; MG_GIVEN=1; shift 2 ;; --mg-conclusion) MG_CONCLUSION=$2; MG_GIVEN=1; shift 2 ;; + --receipt-ok) RECEIPT_OK=$2; shift 2 ;; --registry) REGISTRY=$2; shift 2 ;; --repo-root) ROOT=$2; shift 2 ;; --tsv) TSV=$2; shift 2 ;; @@ -140,7 +146,7 @@ while [ $# -gt 0 ]; do --filterset) FILTERSET=1; shift; if [ $# -gt 0 ]; then FS_TARGETS=$1; shift; fi ;; # same operand rules as --filterset; prints the cargo BUILD flags for the same tokens --build-flags) BUILDFLAGS=1; shift; if [ $# -gt 0 ]; then FS_TARGETS=$1; shift; fi ;; - *) printf 'usage: %s --event EVENT [--comparand REF] [--diff-from FILE] [--pr-head SHA --pr-head-conclusion C] [--mg-run ID --mg-sha SHA --mg-conclusion C] [--registry FILE] [--repo-root DIR] | --filterset [TARGETS] | --tier-of-record [--tsv FILE] [--union-touched --event pull_request --comparand REF] | --self-test\n' "$0" >&2; exit 2 ;; + *) printf 'usage: %s --event EVENT [--comparand REF] [--diff-from FILE] [--pr-head SHA --pr-head-conclusion C] [--mg-run ID --mg-sha SHA --mg-conclusion C] [--receipt-ok 1] [--registry FILE] [--repo-root DIR] | --filterset [TARGETS] | --tier-of-record [--tsv FILE] [--union-touched --event pull_request --comparand REF] | --self-test\n' "$0" >&2; exit 2 ;; esac done @@ -309,6 +315,22 @@ push_reuse_why() { decide() { local df rc=0 + # PMAT-5737 RECEIPT ARM, before any diff selection. The caller (ci/sections.yml) passes --receipt-ok 1 only + # after scripts/ci_receipt_verify.sh printed RECEIPT_OK=1 for THIS tree (flow009.receipt/1: signature, tree, patch-ids, tickets, toolchain, manifest). + # Only a pull_request takes it (FLOW-009 RC-3: the receipt lives in the PR body, a merge_group has none): a push keeps its own mg-reuse arm below, untouched, and a tag + # is full whatever a receipt says. Anything but exactly `1`, or an unreadable tree, falls through unchanged. + case "$EVENT" in + pull_request) + if [ "$RECEIPT_OK" = 1 ]; then + local rref rtree; rref=$(printenv GITHUB_REF || true) + case "$rref" in refs/tags/*) printf 'tier=full\nreason=%s of tag %s: a release tag runs the whole workspace -- a batch receipt is never honoured on a tag (PMAT-5737)\n' "$EVENT" "${rref#refs/tags/}"; return 0 ;; esac # R-RTAG + rtree=$(git -C "$ROOT" rev-parse -q --verify 'HEAD^{tree}' 2>/dev/null || true) + if [[ $rtree =~ ^[0-9a-f]{40}$ ]]; then + printf 'tier=reuse\ncite=receipt:%s\nreceipt=ok\nreason=%s: a signed batch receipt covers this exact tree (%s) -- signature, tree, patch-ids, tickets, toolchain and manifest verified by ci_receipt_verify.sh (PMAT-5737)\n' "$rtree" "$EVENT" "${rtree:0:9}" + return 0 + fi + fi ;; + esac case "$EVENT" in schedule|workflow_dispatch) printf 'tier=full\nreason=%s event: the whole workspace, every feature-gated suite -- FULL lives here (coverage-nightly, full-nightly, the pre-publish dogfood; 67-E2 decision D-1)\n' "$EVENT" ;; @@ -770,6 +792,22 @@ self_test() { row 0 " ...while apr-cli, whose registry carries an UNRESOLVED whole-lib row, still runs its WHOLE lib (fail open)" '^ALL-PRESENT$' \ contains_all "$td/real-fs.txt" "(package(apr-cli) & kind(lib))" row 0 " ...with no narrower apr-cli module atom beside it" '^ABSENT$' lacks "$td/real-fs.txt" "package(apr-cli) & kind(lib) & test" + # PMAT-5737 receipt arm: a verified signed batch receipt (ci_receipt_verify.sh -> --receipt-ok 1) makes a + # pull_request or merge_group tier=reuse, evaluated BEFORE diff selection. Never a tag, never a push. + RTREE=$(git -C "$td/q-leaf" rev-parse 'HEAD^{tree}') + row 0 "RECEIPT pull_request + --receipt-ok 1 -> tier=reuse, cite=receipt:, receipt=ok (no diff needed)" "^tier=reuse cite=receipt:$RTREE receipt=ok\$" bash -c "o=\$(bash '$T' --event pull_request --repo-root '$td/q-leaf' --receipt-ok 1); printf '%s %s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep '^cite=' <<< \"\$o\")\" \"\$(grep '^receipt=' <<< \"\$o\")\"" + row 0 "MUTANT merge_group + --receipt-ok 1 -> the receipt arm is pull_request only: no receipt line (a merge_group has no PR body)" '^NO-RECEIPT$' bash -c "o=\$(env -u MG_BASE_SHA bash '$T' --event merge_group --repo-root '$td/q-leaf' --receipt-ok 1 2>/dev/null || true); grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT" + row 0 "MUTANT --receipt-ok 0 -> the PR's own selection (quick), no receipt line" '^tier=quick NO-RECEIPT$' bash -c "o=\$(bash '$T' --event pull_request --diff-from '$td/d-scripts.txt' --receipt-ok 0); printf '%s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" + row 0 "MUTANT --receipt-ok 2 / anything but 1 -> unchanged (quick)" '^tier=quick$' bash -c "bash '$T' --event pull_request --diff-from '$td/d-scripts.txt' --receipt-ok yes | grep '^tier='" + row 0 "MUTANT no --receipt-ok flag -> unchanged (quick)" '^tier=quick NO-RECEIPT$' bash -c "o=\$(bash '$T' --event pull_request --diff-from '$td/d-scripts.txt'); printf '%s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" + row 0 "MUTANT a root-manifest diff with --receipt-ok 0 stays full (the receipt arm does not widen anything)" '^tier=full$' bash -c "bash '$T' --event pull_request --diff-from '$td/d-root.txt' --receipt-ok 0 | grep '^tier='" + row 0 "MUTANT TAG ref + pull_request + --receipt-ok 1 -> tier=full, no receipt line" '^tier=full NO-RECEIPT$' bash -c "o=\$(GITHUB_REF=refs/tags/v9.9.9 bash '$T' --event pull_request --repo-root '$td/q-leaf' --receipt-ok 1); printf '%s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" + row 0 "MUTANT TAG push + --receipt-ok 1 -> tier=full, no receipt line" '^tier=full NO-RECEIPT$' bash -c "o=\$(GITHUB_REF=refs/tags/v9.9.9 bash '$T' --event push --repo-root '$td/s-leaf' --receipt-ok 1); printf '%s %s\\n' \"\$(grep '^tier=' <<< \"\$o\" | head -n1)\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" + row 0 "MUTANT a push never takes the receipt arm: --receipt-ok 1 with no mg result -> the push's own tier, no receipt line" '^NO-RECEIPT$' bash -c "o=\$(bash '$T' --event push --repo-root '$td/s-leaf' --receipt-ok 1); grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT" + row 0 " ...and a push WITH the mg result still reuses on the mg arm, cite = the run id, no receipt line" '^tier=reuse cite=123456 NO-RECEIPT$' bash -c "o=\$(bash '$T' --event push --repo-root '$td/s-leaf' $MGARGS --receipt-ok 1); printf '%s %s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep '^cite=' <<< \"\$o\")\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" + sed 's|"\$RECEIPT_OK" = 1|"$RECEIPT_OK" != 1|' "$T" > "$td/mutant-receipt.sh" + row 0 " (receipt mutant file really differs from the script)" '^DIFFERS$' bash -c "if cmp -s '$T' '$td/mutant-receipt.sh'; then echo SAME; else echo DIFFERS; fi" + row 0 "MUTANT: an inverted receipt test makes --receipt-ok 0 reuse -> the rows above go RED" '^MUTANT-REUSE$' bash -c "o=\$(bash '$td/mutant-receipt.sh' --event pull_request --repo-root '$td/q-leaf' --diff-from '$td/d-scripts.txt' --receipt-ok 0); if grep -q '^receipt=ok' <<< \"\$o\"; then echo MUTANT-REUSE; else echo SURVIVED; fi" printf '\n%s checks, %s failed\n' "$n" "$red"; [ "$red" -eq 0 ] } From d4af52a3fdf58124849ca6300f65ab92f9d05644 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 16:57:58 +0200 Subject: [PATCH 3/3] Revert "PMAT-5737: signed batch receipt can stand in for workspace-test tests on PR/merge_group; fails closed (Refs #5758)" This reverts commit bd06041e181b2bdc53809834f1d3806aaf5670fd. --- .flow009/batcher.pub | 4 - .github/workflows/ci.yml | 16 -- ci/sections.yml | 90 +------ scripts/check_receipt_trust_boundary.sh | 59 ----- scripts/ci/manifest_id.sh | 32 --- scripts/ci/toolchain_id.sh | 24 -- scripts/ci_mg_reuse.sh | 4 - scripts/ci_receipt_verify.sh | 320 ------------------------ scripts/ci_test_tier.sh | 42 +--- 9 files changed, 4 insertions(+), 587 deletions(-) delete mode 100644 .flow009/batcher.pub delete mode 100755 scripts/check_receipt_trust_boundary.sh delete mode 100755 scripts/ci/manifest_id.sh delete mode 100755 scripts/ci/toolchain_id.sh delete mode 100755 scripts/ci_receipt_verify.sh diff --git a/.flow009/batcher.pub b/.flow009/batcher.pub deleted file mode 100644 index ccc1847a23..0000000000 --- a/.flow009/batcher.pub +++ /dev/null @@ -1,4 +0,0 @@ -# FLOW-009 RC-2: the public half of the lambda batcher receipt key. The private half lives only on -# lambda-labs (~/.config/pmat-batcher/receipt_ed25519, infra forjar resource pmat-batcher-receipt-key) -# and never enters git. Fingerprint: SHA256:8un1DYXhrsJ0e+6UM3w8KSE9CKBpfDVSRG41yL9wS5o (ED25519). -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINna9SBgvdA0Te1LhyTa6jXbDu8SgJmiDDPhHkLmljw7 flow009-batcher diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d222bca47..408fa9a820 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -523,22 +523,6 @@ jobs: runs-on: [self-hosted, Linux, clean-room] timeout-minutes: 5 steps: - # A receipt-reuse run (tier=reuse, PMAT-5737) runs no test step; the shards stage the tier they decided, so a - # reuse shard fails this marker and mg-reuse-wt never cites that run on the merge queue or main (R:k6-wt-ran). - - uses: actions/download-artifact@v4 - with: - pattern: sigma-shard-* - path: ${{ runner.temp }}/sigma - - name: no shard ran tier reuse - run: | - set -euo pipefail - n=0 - for f in "$RUNNER_TEMP"/sigma/sigma-shard-*/tier; do - [ -f "$f" ] || { echo "::error::a shard staged no tier file"; exit 1; } - n=$((n+1)) - [ "$(cat "$f")" != reuse ] || { echo "::error::$f is tier reuse: this run did not test the tree"; exit 1; } - done - [ "$n" -ge 1 ] || { echo "::error::no shard tier files"; exit 1; } - run: echo "this run owes and runs the test suite" workspace-test-shard: # T43b: a merge_group entry whose tree equals a green PR run's skips the shards (mg-reuse-wt); anything else runs them diff --git a/ci/sections.yml b/ci/sections.yml index 3a0db64797..0b4e9b6b9a 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -438,47 +438,8 @@ jobs: fi ;; esac - # PMAT-5737 batch receipt (FLOW-009 RC-3): the batcher puts the signed receipt in the PR BODY, in a fenced - # block `flow009.receipt/1`. It is read from the event payload -- no API call, no comment, no ref. The - # transport is untrusted, the SIGNATURE is the trust, checked against the BASE commit's .flow009/batcher.pub. - # pull_request ONLY (a merge_group has no PR body; a push and a tag never take it). Any doubt leaves - # --receipt-ok off and the PR/queue tier is decided as before: PG-3, never red. - case "${GITHUB_EVENT_NAME}" in - pull_request) - case "${GITHUB_REF}" in - refs/tags/*) printf 'receipt: tag ref -- never honoured\n' ;; - *) - # TRUST BOUNDARY (PMAT-1889): the verifier, its helpers and the pubkey come from the BASE commit - # (pull_request.base.sha), never from this PR's tree -- otherwise a PR commits its own key (or edits the - # verifier), signs its own receipt and skips the tests it is judged by. cwd stays the PR tree, so the - # toolchain and the manifest are still computed over it. Any of them absent on base (true until the PR - # that adds them merges) or the base unfetchable => no receipt, the full gate: fail closed, never red. - rhead=$(jq -r '.pull_request.head.sha // ""' "${GITHUB_EVENT_PATH}" 2>/dev/null || true) - rbase=$(jq -r '.pull_request.base.sha // ""' "${GITHUB_EVENT_PATH}" 2>/dev/null || true) - # a depth-1 merge checkout hides HEAD^1, the PR head and the base sha: fetch all with enough history for - # the merge-base. A failed fetch leaves the verifier to refuse (R-HEADMISSING) -> the full path. - if [ -n "$rhead" ] && [ -n "$rbase" ] && bash scripts/ci/fetch_p6.sh --no-tags --depth=500 origin "+refs/heads/${GITHUB_BASE_REF}:refs/remotes/origin/${GITHUB_BASE_REF}" "$rhead" "$rbase" >/dev/null 2>&1; then - export RECEIPT_BASE="origin/${GITHUB_BASE_REF}" - trusted="$RUNNER_TEMP/flow009-trusted" - rm -rf "${trusted:?}"; mkdir -p "$trusted" - tok=1 - for tf in scripts/ci_receipt_verify.sh scripts/ci/toolchain_id.sh scripts/ci/manifest_id.sh .flow009/batcher.pub; do - git show "$rbase:$tf" > "$trusted/$(basename "$tf")" 2>/dev/null || tok=0 - done - if [ "$tok" = 1 ]; then - rv=$(FLOW009_PUBKEY="$trusted/batcher.pub" bash "$trusted/ci_receipt_verify.sh" "${GITHUB_REPOSITORY}" "${GITHUB_EVENT_PATH}" "$trusted/batcher.pub" "$trusted/toolchain_id.sh" "$trusted/manifest_id.sh" 2>/dev/null | grep -v '^coverage=') || true - else - rv="RECEIPT_OK=0 reason=verifier or pubkey absent on the base commit -- full gate" - fi - else - rv="RECEIPT_OK=0 reason=base commit not fetchable -- full gate" - fi - printf 'receipt: %s\n' "$rv" - if [ "$rv" = "RECEIPT_OK=1" ]; then args+=(--receipt-ok 1); fi ;; - esac ;; - esac bash scripts/ci_test_tier.sh "${args[@]}" | tee "$RUNNER_TEMP/tier.txt" - grep -E '^(tier|crates|targets|check_workspace|reason|cite|receipt)=' "$RUNNER_TEMP/tier.txt" >> "$GITHUB_OUTPUT" + grep -E '^(tier|crates|targets|check_workspace|reason|cite)=' "$RUNNER_TEMP/tier.txt" >> "$GITHUB_OUTPUT" # #4502 row 2: lint_passes_on_real_contracts (aprender-contracts) requires the # refinement gate to RUN whenever CI is set, and that gate compares HEAD with # merge-base(HEAD, origin/main), else the origin/main tip. This section clones at @@ -1139,46 +1100,6 @@ jobs: -e CARGO_TERM_COLOR=never \ "$IMAGE" \ cargo check --workspace --all-targets --locked - # PMAT-5737 receipt reuse: a signed batch receipt covers this exact tree, so the tests are not re-run. What - # a receipt does NOT cover is that this merge result still compiles and is clean, so shard 1 runs the three - # cheap whole-workspace checks (the same docker invocation as the over-cap step above); the other shards - # print the citation. tier=reuse with receipt=ok is only ever a pull_request. - - name: "Receipt reuse: cargo check + fmt + clippy over the workspace (PMAT-5737, shard 1)" - if: steps.tier.outputs.tier == 'reuse' && steps.tier.outputs.receipt == 'ok' && matrix.shard == 1 - timeout-minutes: 30 - env: - REASON: ${{ steps.tier.outputs.reason }} - CITE: ${{ steps.tier.outputs.cite }} - run: | - set -euo pipefail - printf 'workspace-test REUSED: %s\ncited: %s\n' "$REASON" "$CITE" - docker run --rm \ - -e CI -e GITHUB_ACTIONS -e GITHUB_REF -e GITHUB_SHA -e GITHUB_REPOSITORY -e GITHUB_RUN_ID -e GITHUB_EVENT_NAME -e GITHUB_WORKFLOW \ - -v "${GITHUB_WORKSPACE}:/workspace" \ - -v "/mnt/nvme-raid0/cargo-ci/registry/${PR_OR_REF}:/usr/local/cargo/registry" \ - -v "/mnt/nvme-raid0/targets/aprender-ci/${PR_OR_REF}/run-${GITHUB_RUN_ID}:/workspace/target" \ - -v "${SCCACHE_HOST_DIR}:/sccache" \ - -w /workspace \ - -e CARGO_TARGET_DIR=/workspace/target \ - -e RUSTC_WRAPPER=/usr/local/cargo/bin/sccache \ - -e SCCACHE_DIR=/sccache \ - -e SCCACHE_CACHE_SIZE=50G \ - -e CARGO_INCREMENTAL=0 \ - -e CARGO_BUILD_JOBS=8 \ - -e CARGO_PROFILE_TEST_DEBUG=line-tables-only \ - -e CARGO_PROFILE_DEV_DEBUG=line-tables-only \ - -e CARGO_TERM_COLOR=never \ - "$IMAGE" \ - sh -euc 'cargo check --workspace --all-targets --locked - cargo fmt --all -- --check - cargo clippy --workspace --all-targets -- -D warnings' - - name: "Receipt reuse: cite only (PMAT-5737, shards 2+)" - if: steps.tier.outputs.tier == 'reuse' && steps.tier.outputs.receipt == 'ok' && matrix.shard != 1 - env: - REASON: ${{ steps.tier.outputs.reason }} - CITE: ${{ steps.tier.outputs.cite }} - run: | - printf 'workspace-test REUSED: %s\ncited: %s\n' "$REASON" "$CITE" # BSE-17 reuse: the queue ref's tree is byte-identical to a PR head whose # workspace-test already succeeded; measuring it again is waste, not # evidence. The tier step proved both facts (trees compared by hash, the @@ -1186,7 +1107,7 @@ jobs: # T36: on a push the citation is instead the merge_group run that tested # this very sha, so the label names what is cited for each event. - name: "Reuse: this tree already passed workspace-test (BSE-17 PR head / T36 merge queue)" - if: steps.tier.outputs.tier == 'reuse' && steps.tier.outputs.receipt != 'ok' + if: steps.tier.outputs.tier == 'reuse' run: | printf 'workspace-test REUSED: %s\n' "${{ steps.tier.outputs.reason }}" case "${{ github.event_name }}" in @@ -1533,13 +1454,6 @@ jobs: # behaviour rows, structure rows and every mutant (#4672, #4734). - name: coverage receipt is sha-keyed and read only by the release gate (#4672) run: setsid --wait bash scripts/check_coverage_receipt.sh - # PMAT-1889: the receipt step in the tier job must run the BASE commit's verifier and key, never the PR tree's. - # This is that rule as a static check on this very file, with a mutant per way of breaking it; it also runs the - # verifier's own case table (RC-1..RC-5, FLOW009_PUBKEY, coverage= line). - - name: the receipt step judges a PR by the base commit's verifier and key (PMAT-1889) - run: | - setsid --wait bash scripts/check_receipt_trust_boundary.sh --self-test - setsid --wait bash scripts/ci_receipt_verify.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_receipt_trust_boundary.sh b/scripts/check_receipt_trust_boundary.sh deleted file mode 100755 index 4fb3040808..0000000000 --- a/scripts/check_receipt_trust_boundary.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# check_receipt_trust_boundary.sh - the CI receipt step must judge a PR by the BASE commit's verifier and key (PMAT-1889). -# -# check_receipt_trust_boundary.sh [sections-file] (default ci/sections.yml) -# check_receipt_trust_boundary.sh --self-test -# -# A static check on the workflow text. The receipt step used to run scripts/ci_receipt_verify.sh and read -# .flow009/batcher.pub from the PR's OWN tree, so a PR could commit its own ed25519 key, sign its own receipt and skip -# the tests it is judged by. The step must instead `git show "$rbase:"` (rbase = pull_request.base.sha) the -# verifier, toolchain_id.sh, manifest_id.sh and .flow009/batcher.pub into a temp dir, execute THOSE copies, and hand the -# base key over as FLOW009_PUBKEY. Prints one line per failed rule (`# T-`), exit 1; exit 0 and `trust boundary ok`. -# Self-test: the real file must pass, and each mutant (tree copy, no git show, no base sha, no pubkey env) must go red. -set -uo pipefail - -check() { - local f=$1 bad=0 - [ -f "$f" ] || { echo "no such file: $f # T-NOFILE"; return 1; } - if grep -n -E 'bash[[:space:]]+(\./)?scripts/ci_receipt_verify\.sh[[:space:]]+"?\$\{?GITHUB_REPOSITORY' "$f" >/dev/null; then - echo "the step executes the PR tree's scripts/ci_receipt_verify.sh # T-TREECOPY"; bad=1 - fi - local nl; nl=$(grep -v -E '^[[:space:]]+for[[:space:]]' "$f" | grep -c -E '^[^#]*ci_receipt_verify\.sh[^#]*[[:space:]]\.flow009/batcher\.pub' || true) - if [ "$nl" != 0 ]; then - echo "the step passes the tree's .flow009/batcher.pub path # T-TREEKEY"; bad=1 - fi - grep -q -F 'git show "$rbase:$tf"' "$f" || { echo "no git show of the base sha for the trusted copies # T-NOGITSHOW"; bad=1; } - grep -q -F "jq -r '.pull_request.base.sha" "$f" || { echo "base sha is not read from pull_request.base.sha # T-NOBASE"; bad=1; } - local loop - loop=$(grep -E 'for tf in .*ci_receipt_verify\.sh' "$f" || true) - case $loop in *scripts/ci_receipt_verify.sh*scripts/ci/toolchain_id.sh*scripts/ci/manifest_id.sh*.flow009/batcher.pub*) ;; *) - echo "the base-copy loop lacks the verifier, a helper or the pubkey # T-LOOP"; bad=1 ;; esac - grep -q -F 'FLOW009_PUBKEY="$trusted/batcher.pub" bash "$trusted/ci_receipt_verify.sh"' "$f" \ - || { echo "the base verifier copy is not run with FLOW009_PUBKEY pinned to the base key # T-NOPUBENV"; bad=1; } - return $bad -} - -self_test() { - local here real td n=0 red=0 m - here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) - real=$here/../ci/sections.yml - td=$(mktemp -d "${TMPDIR:-/tmp}/rtb.XXXXXX") || return 2 - trap 'rm -rf "${td:?}"' RETURN - n=$((n + 1)); if check "$real" >/dev/null; then echo "ok row $n the real ci/sections.yml passes"; else echo "FAIL row $n the real ci/sections.yml"; check "$real"; red=1; fi - mut() { # mut LABEL WANT-MARKER SED-EXPR - n=$((n + 1)); sed -e "$3" "$real" > "$td/m.yml" - if cmp -s "$real" "$td/m.yml"; then echo "FAIL row $n $1 (mutation did not apply)"; red=1; return; fi - m=$(check "$td/m.yml") - case $m in *"$2"*) echo "ok row $n MUTANT $1 -> red";; *) echo "FAIL row $n MUTANT $1 stayed green/other: $m"; red=1;; esac - } - mut "step runs the tree copy of the verifier" 'T-TREECOPY' 's|FLOW009_PUBKEY="\$trusted/batcher.pub" bash "\$trusted/ci_receipt_verify.sh" "\${GITHUB_REPOSITORY}"|bash scripts/ci_receipt_verify.sh "${GITHUB_REPOSITORY}"|' - mut "step passes the tree key path" 'T-TREEKEY' 's|"\$trusted/batcher.pub" "\$trusted/toolchain_id.sh"|.flow009/batcher.pub "$trusted/toolchain_id.sh"|' - mut "git show of the base sha removed" 'T-NOGITSHOW' 's|git show "\$rbase:\$tf"|cat "$tf"|' - mut "base sha not read from the event" 'T-NOBASE' "s|jq -r '.pull_request.base.sha|jq -r '.pull_request.head.sha|" - mut "pubkey dropped from the base-copy loop" 'T-LOOP' 's|manifest_id.sh .flow009/batcher.pub; do|manifest_id.sh; do|' - mut "FLOW009_PUBKEY not pinned" 'T-NOPUBENV' 's|FLOW009_PUBKEY="\$trusted/batcher.pub" bash|bash|' - printf '\nthis self-test ran %s checks, %s failed\n' "$n" "$red"; [ "$red" -eq 0 ] -} - -if [ "${1:-}" = "--self-test" ]; then self_test; exit $?; fi -if check "${1:-ci/sections.yml}"; then echo "trust boundary ok"; else exit 1; fi diff --git a/scripts/ci/manifest_id.sh b/scripts/ci/manifest_id.sh deleted file mode 100755 index f13449f82d..0000000000 --- a/scripts/ci/manifest_id.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash -# manifest_id.sh - one sha256 hex naming the gate definition and its test list (PMAT-5737, FLOW-009 RC-1 `manifest`). -# -# manifest_id.sh [repo-root] -# -# Prints sha256 over, byte for byte, in this order: -# for each file in GATE_FILES (fixed order): "--file \n" + its bytes (a missing file hashes as "--missing\n") -# "--tests--\n" + the TEST LIST -# GATE_FILES: Makefile (holds `gate:`), scripts/gate_touched_crates.sh, scripts/guard_tree.sh, -# scripts/ci_test_tier.sh, scripts/ci_receipt_verify.sh, scripts/ci/toolchain_id.sh, scripts/ci/manifest_id.sh. -# TEST LIST: the workspace's test targets, `cargo metadata --no-deps --locked --offline --format-version 1` -# reduced by jq to a sorted `[{name, targets:[{name, kind}]}]` -- cheap (no build) and it changes whenever a -# crate or a test/bench/example target is added, removed or renamed. It does NOT name individual #[test] fns -# (that needs a build); a change to which tests exist INSIDE a target is not caught here, only by the gate. -# Any gate-definition or test-target change invalidates every outstanding receipt, by design. -# A missing cargo/jq or a metadata failure: prints nothing on stdout, a reason on stderr, exit 1. -set -uo pipefail - -root=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)} -GATE_FILES=(Makefile scripts/gate_touched_crates.sh scripts/guard_tree.sh scripts/ci_test_tier.sh scripts/ci_receipt_verify.sh scripts/ci/toolchain_id.sh scripts/ci/manifest_id.sh) -command -v jq >/dev/null 2>&1 || { echo "manifest_id: jq missing" >&2; exit 1; } -tests=$(cd "$root" && cargo metadata --no-deps --locked --offline --format-version 1 2>/dev/null \ - | jq -S -c '[.packages[] | {name, targets: [.targets[] | {name, kind}] | sort_by(.name, (.kind | join(",")))}] | sort_by(.name)') \ - || { echo "manifest_id: cargo metadata failed" >&2; exit 1; } -[ -n "$tests" ] && [ "$tests" != null ] || { echo "manifest_id: empty test list" >&2; exit 1; } -{ - for f in "${GATE_FILES[@]}"; do - printf -- '--file %s\n' "$f" - if [ -f "$root/$f" ]; then cat "$root/$f"; else printf -- '--missing\n'; fi - done - printf -- '--tests--\n%s\n' "$tests" -} | sha256sum | cut -d' ' -f1 diff --git a/scripts/ci/toolchain_id.sh b/scripts/ci/toolchain_id.sh deleted file mode 100755 index fafdf08884..0000000000 --- a/scripts/ci/toolchain_id.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# toolchain_id.sh - one sha256 hex naming the toolchain a gate ran on (PMAT-5737, FLOW-009 RC-1 `toolchain`). -# -# toolchain_id.sh [repo-root] -# -# Prints sha256 over, byte for byte, in this order: -# /rust-toolchain.toml, or nothing when absent> -# "--rustc--\n" + the full output of `rustc -vV` -# "--image--\n" + "image=none\n" -# The gate container/image digest term is the literal `image=none`: lambda's `make gate` runs -# UNCONTAINERISED. A containerised gate MUST hash its image digest here instead (e.g. `image=sha256:`), -# and a receipt signed under one image term never verifies under another. infra's script -# (fleet/receipts/receipt.sh toolchain) hashes the same three terms in the same order. -# No rustc: prints nothing on stdout, a reason on stderr, exit 1. -set -uo pipefail - -root=${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)} -vv=$(rustc -vV 2>/dev/null) || { echo "toolchain_id: rustc -vV failed" >&2; exit 1; } -[ -n "$vv" ] || { echo "toolchain_id: rustc -vV printed nothing" >&2; exit 1; } -{ - if [ -f "$root/rust-toolchain.toml" ]; then cat "$root/rust-toolchain.toml"; fi - printf -- '--rustc--\n%s\n' "$vv" - printf -- '--image--\nimage=none\n' -} | sha256sum | cut -d' ' -f1 diff --git a/scripts/ci_mg_reuse.sh b/scripts/ci_mg_reuse.sh index bf6f226667..45f685ac24 100755 --- a/scripts/ci_mg_reuse.sh +++ b/scripts/ci_mg_reuse.sh @@ -645,7 +645,6 @@ STUB mgshape() { # mgshape FILE -> one line per violation awk -v tok='(^|[^A-Za-z0-9_-])mg-reuse($|[^A-Za-z0-9_-])' -v want="if: github.event_name == 'push' && github.ref == 'refs/heads/main'" ' /^ [A-Za-z0-9_-]+:$/ { j = $1; sub(/:$/, "", j); seen[j] = 1; next } - { jbody[j] = jbody[j] "\n" $0 } /^ if:/ { jif[j] = $0; sub(/^ /, "", jif[j]) } /^ runs-on:/ { jrun[j] = $0 } /^ needs:/ || (inn && /^ /) { jneeds[j] = jneeds[j] $0 } @@ -657,8 +656,6 @@ STUB if (jif["mg-reuse"] ~ /merge_group/) print "mg-reuse if names merge_group: a queue entry could reuse" # once the receipt judge exists, the test-ran marker must be keyed on its verdict, else a receipt run reads as tested if (("receipt-judge" in seen) && (jneeds["workspace-test-ran"] !~ /receipt-judge/ || jif["workspace-test-ran"] !~ /receipt-judge\.outputs\.ok/)) print "workspace-test-ran ignores receipt-judge: a receipt-reuse run would read as tested" - # a receipt-reuse run (tier=reuse) runs no tests: the marker must read the staged shard tier and refuse reuse (#5944) - if (jbody["workspace-test-ran"] !~ /sigma-shard/ || jbody["workspace-test-ran"] !~ /!= reuse/) print "workspace-test-ran does not refuse tier reuse: a receipt-reuse run would read as tested" # the marker must prove the shards ran: it needs them and its if requires their success (else a run with skipped shards owns it) if (jneeds["workspace-test-ran"] !~ /workspace-test-shard/) print "workspace-test-ran does not need workspace-test-shard: the marker no longer proves the shards ran" if (jif["workspace-test-ran"] !~ /needs\.workspace-test-shard\.result == .success./) print "workspace-test-ran if lacks the shard success clause: skipped or failed shards would still own the marker" @@ -696,7 +693,6 @@ STUB mgmut ran-marker-needs-judge-but-if-ignores-it "workspace-test-ran ignores receipt-judge" 's#^ workspace-test-ran:$# receipt-judge:\n runs-on: x\n workspace-test-ran:\n needs: [receipt-judge]#' mgmut ran-marker-drops-shard-needs "workspace-test-ran does not need workspace-test-shard" '/^ workspace-test-ran:$/,/^ runs-on:/{/^ needs: \[workspace-test-shard\]$/d}' mgmut ran-marker-if-drops-shard-result "workspace-test-ran if lacks the shard success clause" "/^ workspace-test-ran:\$/,/^ runs-on:/{s/ \&\& needs.workspace-test-shard.result == 'success'//}" - mgmut ran-marker-ignores-reuse-tier "workspace-test-ran does not refuse tier reuse" '/^ workspace-test-ran:$/,/^ workspace-test-shard:$/{/!= reuse/d}' mgmut mg-reuse-wt-leaves-mq-lane "mg-reuse-wt is not on the mq lane" '/^ mg-reuse-wt:$/,/^ timeout-minutes:/{s/,"mq"\]/,"clean-room"]/}' # Positive control for wire itself: one job of more than 64 KiB (a pipe buffer) whose diff --git a/scripts/ci_receipt_verify.sh b/scripts/ci_receipt_verify.sh deleted file mode 100755 index 0f6327106d..0000000000 --- a/scripts/ci_receipt_verify.sh +++ /dev/null @@ -1,320 +0,0 @@ -#!/usr/bin/env bash -# ci_receipt_verify.sh - does a signed batch receipt in the PR body cover THIS merge tree? -# (PMAT-5737, FLOW-009 receipt format flow009.receipt/1, RC-1..RC-5; infra PMAT-1889 owns the signer.) -# -# ci_receipt_verify.sh [pubkey-file] [toolchain-id-script] [manifest-id-script] -# ci_receipt_verify.sh --self-test -# -# Run from the checked-out PR MERGE commit. is $GITHUB_EVENT_PATH: the receipt is read from -# .pull_request.body (RC-3: never an API call, never a comment, never a ref) and the PR head from -# .pull_request.head.sha. Defaults: .flow009/batcher.pub (COMMITTED at this tree), scripts/ci/toolchain_id.sh, -# scripts/ci/manifest_id.sh. Prints on stdout: -# RECEIPT_OK=1 -# RECEIPT_OK=0 reason= -# On RECEIPT_OK=1 a second stdout line `coverage=` follows when that value is numeric (sovereign-ci.yml -# reads it); absent or non-numeric => no such line. FLOW009_PUBKEY (env), when set, is THE pubkey path and overrides the -# argument and the default: the caller passes the BASE commit's copy so a PR cannot sign with a key it committed itself. -# exit 0 on RECEIPT_OK=1, 1 otherwise. FAIL CLOSED (PG-3): a missing tool or file, a parse problem or any error is -# RECEIPT_OK=0 and the caller runs the full gate -- never red. Not a tag and not a push: that is the caller's -# (ci/sections.yml runs this for pull_request only; ci_test_tier.sh refuses a tag ref again). -# -# The receipt: a fenced block whose info string is exactly `flow009.receipt/1`, ONE per body, holding a JSON object -# with exactly the RC-1 keys: schema repo batch base_sha tree commits gate_results toolchain manifest steps host -# started finished sig. Signed bytes = `jq -S -c 'del(.sig)'` + LF (the BYTE-IDENTICAL canonicalisation infra's -# fleet/receipts/receipt.sh signs). sig = `ssh-keygen -Y sign -n flow009-receipt` armor, newlines escaped in JSON. -# Verified (RC-5): sig; tree == HEAD^{tree}; commits[].patch_id multiset == `git patch-id --stable` over each -# commit of merge-base($RECEIPT_BASE or HEAD^1, PR head)..PR head; each commits[].ticket ^PMAT-[0-9]+$ and in that commit's message; -# toolchain; manifest. Also required: schema, repo == , gate_results.tests == "pass", steps sha256 hex. -# base_sha and commits[].sha are informational and never compared. -# -# Every refusal is ONE line ending in a `# R-` marker. -set -uo pipefail - -PRINCIPAL=flow009-batcher -NAMESPACE=flow009-receipt -SCHEMA=flow009.receipt/1 -FENCE='```flow009.receipt/1' -CLOSE='```' -TOP_KEYS='["base_sha","batch","commits","finished","gate_results","host","manifest","repo","schema","sig","started","steps","toolchain","tree"]' - -no() { echo "RECEIPT_OK=0 reason=$*"; exit 1; } - -verify() { - local repo=${1:-} event=${2:-} pub=${3:-.flow009/batcher.pub} tcid=${4:-scripts/ci/toolchain_id.sh} manid=${5:-scripts/ci/manifest_id.sh} - local cov td fences rcpt keyline head_tree head_sha base merge_base got i n pid tk sha ok - # FLOW009_PUBKEY (trust boundary, PMAT-1889): when set it IS the pubkey path and wins over the argument and the default, - # so a caller can pin the BASE commit's key and a PR can never supply its own. Set but unreadable => refused below. - if [ -n "${FLOW009_PUBKEY:-}" ]; then pub=$FLOW009_PUBKEY; fi - [ -n "$repo" ] && [ -n "$event" ] || no "usage: " # R-USAGE - [ -f "$event" ] || no "no event file" # R-NOEVENT - command -v jq >/dev/null 2>&1 || no "jq missing" # R-NOJQ - command -v ssh-keygen >/dev/null 2>&1 || no "ssh-keygen missing" # R-NOSSHKEYGEN - command -v git >/dev/null 2>&1 || no "git missing" # R-NOGIT - td=$(mktemp -d "${TMPDIR:-/tmp}/receipt-v.XXXXXX") || no "no tempdir" # R-TMP - trap 'rm -rf "${td:?}"' EXIT - - # (a) transport: the PR body, exactly one fenced flow009.receipt/1 block - jq -e '.pull_request | type == "object"' "$event" >/dev/null 2>&1 || no "event has no pull_request" # R-NOPR - jq -r '.pull_request.body // ""' "$event" 2>/dev/null | tr -d '\r' > "$td/body" || no "cannot read PR body" # R-BODY - fences=$(grep -c -x -F -e "$FENCE" "$td/body" || true) - [ "$fences" = 1 ] || no "PR body has $fences flow009.receipt/1 fenced blocks, want exactly 1" # R-FENCE - awk -v f="$FENCE" -v c="$CLOSE" '$0 == f && !s {s=1; next} s && $0 == c {closed=1; exit} s {print} END {exit closed ? 0 : 1}' "$td/body" > "$td/receipt.json" \ - || no "receipt block is not closed" # R-UNCLOSED - [ -s "$td/receipt.json" ] || no "empty receipt block" # R-EMPTYBLOCK - rcpt=$td/receipt.json - - # (b) shape: exact keys, exact types, no unknown or missing key - jq -e . "$rcpt" >/dev/null 2>&1 || no "receipt is not JSON" # R-JSON - jq -e --argjson k "$TOP_KEYS" 'type == "object" and (keys == $k)' "$rcpt" >/dev/null 2>&1 \ - || no "receipt keys are not exactly the RC-1 set" # R-KEYS - jq -e --arg s "$SCHEMA" '.schema == $s' "$rcpt" >/dev/null 2>&1 || no "schema is not $SCHEMA" # R-SCHEMA - jq -e '[.repo,.batch,.base_sha,.tree,.toolchain,.manifest,.steps,.host,.started,.finished,.sig] | all(type == "string")' "$rcpt" >/dev/null 2>&1 \ - || no "a scalar field is not a string" # R-TYPES - jq -e '(.gate_results | type == "object" and (keys == ["coverage","tests"]))' "$rcpt" >/dev/null 2>&1 \ - || no "gate_results keys are not exactly tests, coverage" # R-GATEKEYS - jq -e '.gate_results.tests == "pass"' "$rcpt" >/dev/null 2>&1 || no "gate_results.tests is not pass" # R-TESTSPASS - jq -e '(.commits | type == "array" and length > 0 and all(type == "object" and (keys == ["patch_id","sha","ticket"]) and ([.[]] | all(type == "string"))))' "$rcpt" >/dev/null 2>&1 \ - || no "commits is not a non-empty array of exactly {ticket, patch_id, sha}" # R-COMMITKEYS - jq -e '(.tree | test("^[0-9a-f]{40}$")) and (.toolchain | test("^[0-9a-f]{64}$")) and (.manifest | test("^[0-9a-f]{64}$")) and (.steps | test("^[0-9a-f]{64}$"))' "$rcpt" >/dev/null 2>&1 \ - || no "tree/toolchain/manifest/steps is not lowercase hex of the right length" # R-HEXFORM - jq -e '.commits | all((.ticket | test("^PMAT-[0-9]+$")) and (.patch_id | test("^[0-9a-f]{40}$")))' "$rcpt" >/dev/null 2>&1 \ - || no "a commit ticket is not PMAT- or a patch_id is not 40-hex" # R-TICKETFORM - - # (c) the signature, against the COMMITTED public key (never one that travelled with the receipt) - [ -f "$pub" ] || no "no committed .flow009/batcher.pub" # R-NOPUB - keyline=$(grep -v -E '^[[:space:]]*(#|$)' "$pub" | head -n 2) - [ "$(printf '%s\n' "$keyline" | grep -c .)" = 1 ] || no "batcher.pub is a placeholder or not exactly one key line" # R-PLACEHOLDER - [[ $keyline =~ ^ssh-ed25519[[:space:]]+[A-Za-z0-9+/]+=*([[:space:]].*)?$ ]] || no "batcher.pub line is not an ssh-ed25519 key" # R-PUBFORM - printf '%s namespaces="%s" %s\n' "$PRINCIPAL" "$NAMESPACE" "$(printf '%s' "$keyline" | cut -d' ' -f1,2)" > "$td/allowed" - jq -r '.sig' "$rcpt" > "$td/sig" 2>/dev/null || no "cannot read sig" # R-SIGREAD - [ -s "$td/sig" ] || no "empty sig" # R-SIGEMPTY - jq -S -c 'del(.sig)' "$rcpt" > "$td/canon" 2>/dev/null || no "cannot canonicalise" # R-CANON - ssh-keygen -Y verify -f "$td/allowed" -I "$PRINCIPAL" -n "$NAMESPACE" -s "$td/sig" < "$td/canon" >/dev/null 2>&1 \ - || no "signature does not verify" # R-SIG - - # (d) the repo and the tree - [ "$(jq -r .repo "$rcpt")" = "$repo" ] || no "repo is not $repo" # R-REPO - head_tree=$(git rev-parse 'HEAD^{tree}' 2>/dev/null) || no "cannot read HEAD^{tree}" # R-HEADTREE - [ "$(jq -r .tree "$rcpt")" = "$head_tree" ] || no "tree is not HEAD's tree $head_tree" # R-TREE - - # (e) the commits: patch-id multiset and tickets, over merge-base(HEAD^1, PR head)..PR head - head_sha=$(jq -r '.pull_request.head.sha // ""' "$event") - [[ $head_sha =~ ^[0-9a-f]{40}$ ]] || no "event has no PR head sha" # R-NOHEAD - git cat-file -e "${head_sha}^{commit}" 2>/dev/null || no "PR head commit is not in this checkout" # R-HEADMISSING - base=$(git rev-parse -q --verify "${RECEIPT_BASE:-HEAD^1}" 2>/dev/null) || no "no base ref (RECEIPT_BASE, else the merge commit's first parent)" # R-NOBASE - merge_base=$(git merge-base "$base" "$head_sha" 2>/dev/null) || no "no merge-base for base and PR head" # R-MERGEBASE - git rev-list --reverse --no-merges "${merge_base}..${head_sha}" > "$td/revs" 2>/dev/null || no "cannot list PR commits" # R-REVLIST - : > "$td/gitcommits" - : > "$td/used" - while read -r got; do - [ -n "$got" ] || continue - i=$(git diff-tree -p --no-color "$got" 2>/dev/null | git patch-id --stable | cut -d' ' -f1) - [[ $i =~ ^[0-9a-f]{40}$ ]] || no "commit $got has no patch-id (empty diff?)" # R-NOPATCHID - printf '%s %s\n' "$i" "$got" >> "$td/gitcommits" - done < "$td/revs" - n=$(jq '.commits | length' "$rcpt") - [ "$(grep -c . "$td/gitcommits")" = "$n" ] || no "receipt names $n commits, the PR has $(grep -c . "$td/gitcommits")" # R-COMMITCOUNT - [ "$(jq -r '.commits[].patch_id' "$rcpt" | sort | paste -sd, -)" = "$(cut -d' ' -f1 "$td/gitcommits" | sort | paste -sd, -)" ] \ - || no "commits[].patch_id multiset differs from git patch-id --stable over the PR" # R-PATCHIDS - for ((i = 0; i < n; i++)); do - ok=0 - pid=$(jq -r ".commits[$i].patch_id" "$rcpt"); tk=$(jq -r ".commits[$i].ticket" "$rcpt") - while read -r got sha; do - [ "$got" = "$pid" ] || continue - if grep -q -x -F -e "$sha" "$td/used"; then continue; fi - if git log -1 --format=%B "$sha" | grep -q -E "(^|[^0-9A-Za-z])${tk}([^0-9]|$)"; then ok=1; echo "$sha" >> "$td/used"; break; fi - done < "$td/gitcommits" - [ "$ok" = 1 ] || no "ticket $tk is not in the message of the commit with patch_id $pid" # R-TICKET - done - - # (f) the toolchain and the manifest, as THIS checkout computes them - # The helpers run from the BASE commit's trusted dir in CI, so they must be told the PR checkout: their own - # location is not the tree the receipt was signed over (rust-toolchain.toml, the gate files, cargo metadata). - root=$(git rev-parse --show-toplevel 2>/dev/null) || no "cannot find the checkout root" # R-ROOT - [ -f "$tcid" ] || no "no toolchain-id script" # R-NOTCID - got=$(bash "$tcid" "$root" 2>/dev/null) || no "toolchain-id script failed" # R-TCRUN - [[ $got =~ ^[0-9a-f]{64}$ ]] || no "toolchain-id script printed no sha256" # R-TCOUT - [ "$(jq -r .toolchain "$rcpt")" = "$got" ] || no "toolchain differs from this CI's toolchain" # R-TOOLCHAIN - [ -f "$manid" ] || no "no manifest-id script" # R-NOMAN - got=$(bash "$manid" "$root" 2>/dev/null) || no "manifest-id script failed" # R-MANRUN - [[ $got =~ ^[0-9a-f]{64}$ ]] || no "manifest-id script printed no sha256" # R-MANOUT - [ "$(jq -r .manifest "$rcpt")" = "$got" ] || no "manifest differs from this checkout's gate definition and test list" # R-MANIFEST - echo "RECEIPT_OK=1" - # sovereign-ci.yml reads ONE `coverage=` stdout line on exit 0; absent or non-numeric => no line (still OK). - cov=$(jq -r '.gate_results.coverage // ""' "$rcpt") - if [[ $cov =~ ^[0-9]+([.][0-9]+)?$ ]]; then echo "coverage=$cov"; fi - exit 0 -} - -self_test() { - local td n=0 red=0 out rc here tc man tree head pid1 pid2 pidx - here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) - td=$(mktemp -d "${TMPDIR:-/tmp}/receipt-verify.XXXXXX") || { echo "self-test: no tempdir" >&2; return 2; } - trap 'rm -rf "${td:?}"' RETURN - command -v ssh-keygen >/dev/null 2>&1 && command -v jq >/dev/null 2>&1 || { echo "self-test: ssh-keygen or jq missing" >&2; return 2; } - # throwaway keys, born and deleted inside $td - ssh-keygen -q -t ed25519 -N '' -C t -f "$td/k" >/dev/null 2>&1 || { echo "self-test: keygen failed" >&2; return 2; } - ssh-keygen -q -t ed25519 -N '' -C t -f "$td/other" >/dev/null 2>&1 || { echo "self-test: keygen failed" >&2; return 2; } - cp "$td/k.pub" "$td/pub" - printf '# PLACEHOLDER\n' > "$td/pub.placeholder" - tc=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa - man=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb - printf '#!/usr/bin/env bash\nprintf "%%s\\n" %s\n' "$tc" > "$td/tcid.sh" - printf '#!/usr/bin/env bash\nprintf "%%s\\n" %s\n' "$man" > "$td/manid.sh" - printf '#!/usr/bin/env bash\nexit 1\n' > "$td/bad.sh" - # a throwaway repo shaped like a PR merge checkout: main -> merge(main, PR head); PR = 2 commits - git init -q -b main "$td/r" || { echo "self-test: git init failed" >&2; return 2; } - ( cd "$td/r" && export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t \ - GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0=/dev/null \ - && printf 'x\n' > f && git add f && git commit -q -m base \ - && git checkout -q -b pr && printf 'a\n' > a && git add a && git commit -q -m 'PMAT-11: add a' \ - && printf 'b\n' > b && git add b && git commit -q -m 'PMAT-22: add b' \ - && git checkout -q main && printf 'm\n' > m && git add m && git commit -q -m 'main moves' \ - && git merge -q --no-ff -m merge pr ) || { echo "self-test: repo build failed" >&2; return 2; } - tree=$(git -C "$td/r" rev-parse 'HEAD^{tree}'); head=$(git -C "$td/r" rev-parse pr) - pid1=$(git -C "$td/r" diff-tree -p "pr~1" | git patch-id --stable | cut -d' ' -f1) - pid2=$(git -C "$td/r" diff-tree -p "pr" | git patch-id --stable | cut -d' ' -f1) - pidx=$(git -C "$td/r" diff-tree -p "main~1" | git patch-id --stable | cut -d' ' -f1) - jq -n --arg t "$tree" --arg tc "$tc" --arg m "$man" --arg p1 "$pid1" --arg p2 "$pid2" '{ - schema: "flow009.receipt/1", repo: "paiml/aprender", batch: "batch/aprender/1", base_sha: "0000", - tree: $t, commits: [{ticket: "PMAT-11", patch_id: $p1, sha: "x"}, {ticket: "PMAT-22", patch_id: $p2, sha: "y"}], - gate_results: {tests: "pass", coverage: "95.1"}, toolchain: $tc, manifest: $m, - steps: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - host: "lambda-labs", started: "2026-10-19T00:00:00Z", finished: "2026-10-19T00:10:00Z"}' > "$td/base.json" - # mk NAME FILTER [--key K] [--ns N] [--post FILTER] [--fences N] [--nofence]: NAME.event = an event file whose - # body carries the receipt. FILTER edits the receipt BEFORE signing, --post AFTER (the old sig stays). - mk() { - local name=$1 filt=$2 key=$td/k ns=$NAMESPACE post=. fences=1 nofence=0 f; shift 2 - while [ $# -gt 0 ]; do - case $1 in - --key) key=$2; shift 2 ;; - --ns) ns=$2; shift 2 ;; - --post) post=$2; shift 2 ;; - --fences) fences=$2; shift 2 ;; - --nofence) nofence=1; shift ;; - *) shift ;; - esac - done - jq "$filt" "$td/base.json" > "$td/$name.pre" - jq -S -c 'del(.sig)' "$td/$name.pre" > "$td/$name.canon" - rm -f "${td:?}/$name.canon.sig" - ssh-keygen -q -Y sign -f "$key" -n "$ns" "$td/$name.canon" >/dev/null 2>&1 - jq --rawfile s "$td/$name.canon.sig" '. + {sig: $s}' "$td/$name.pre" | jq "$post" > "$td/$name.json" - { printf 'Batch PR\r\n\r\n' - if [ "$nofence" = 0 ]; then - for ((f = 0; f < fences; f++)); do printf '```flow009.receipt/1\r\n'; cat "$td/$name.json"; printf '\r\n```\r\n\r\n'; done - else printf '```json\n%s\n```\n' "$(cat "$td/$name.json")"; fi; } > "$td/$name.body" - jq -n --rawfile b "$td/$name.body" --arg h "$head" '{pull_request: {body: $b, head: {sha: $h}}}' > "$td/$name.event" - } - chk() { # (env FP= sets FLOW009_PUBKEY, cov= the expected coverage line) chk WANT LABEL NAME [pub] [tcid] [manid] (runs inside the throwaway repo) -- set `why=` before a no-row: the reason must contain it - local want=$1 label=$2 name=$3 pub=${4:-$td/pub} tcs=${5:-$td/tcid.sh} mans=${6:-$td/manid.sh} - n=$((n + 1)); rc=0 - out=$(cd "$td/r" && env ${FP:+FLOW009_PUBKEY="$FP"} bash "$here/$(basename "${BASH_SOURCE[0]}")" paiml/aprender "$td/$name.event" "$pub" "$tcs" "$mans" 2>&1) || rc=$? - if { [ "$want" = ok ] && [ "$rc" = 0 ] && [ "$out" = "RECEIPT_OK=1"$'\n'"coverage=${cov:-95.1}" ]; } || { [ "$want" = no ] && [ "$rc" = 1 ] && [[ $out == "RECEIPT_OK=0 reason="*"${why:-}"* ]]; }; then - printf 'ok row %-2s %s\n' "$n" "$label" - else printf 'FAIL row %-2s (wanted %s, rc=%s, got "%s") %s\n' "$n" "$want" "$rc" "$out" "$label"; red=1; fi - } - mk good . - chk ok "green path: signed, CRLF body, 2 commits, tree, toolchain, manifest" good - mk wrongtree '.tree="0123456789abcdef0123456789abcdef01234567"' - why="HEAD's tree" chk no "MUTANT wrong tree, validly signed" wrongtree - mk swapped ".commits[0].patch_id=\"$pidx\"" - why='multiset' chk no "MUTANT a patch_id swapped for another commit's, validly signed" swapped - mk missing '.commits |= .[0:1]' - why='receipt names' chk no "MUTANT a PR commit missing from the receipt, validly signed" missing - mk extra ".commits += [{ticket:\"PMAT-33\",patch_id:\"$pidx\",sha:\"z\"}]" - why='receipt names' chk no "MUTANT an extra commit in the receipt, validly signed" extra - mk wrongticket '.commits[0].ticket="PMAT-99"' - why='is not in the message' chk no "MUTANT a ticket not in that commit's message, validly signed" wrongticket - mk badticket '.commits[0].ticket="JIRA-11"' - why='not PMAT-' chk no "MUTANT a ticket not PMAT-, validly signed" badticket - mk prefixticket '.commits[0].ticket="PMAT-1"' - why='is not in the message' chk no "MUTANT ticket PMAT-1 is only a PREFIX of PMAT-11 in the message, validly signed" prefixticket - mk wrongtc '.toolchain="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"' - why='toolchain differs' chk no "MUTANT wrong toolchain, validly signed" wrongtc - mk wrongman '.manifest="dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"' - why='manifest differs' chk no "MUTANT wrong manifest, validly signed" wrongman - mk wrongschema '.schema="flow009.receipt/2"' - why='schema is not' chk no "MUTANT wrong schema, validly signed" wrongschema - mk extrakey '.surprise=1' - why='exactly the RC-1' chk no "MUTANT an extra top-level key, validly signed" extrakey - mk missingkey 'del(.host)' - why='exactly the RC-1' chk no "MUTANT a missing top-level key (host), validly signed" missingkey - mk gatekey '.gate_results.extra="x"' - why='gate_results keys' chk no "MUTANT an unknown key inside gate_results, validly signed" gatekey - mk commitkey '.commits[0].extra="x"' - why='exactly {ticket' chk no "MUTANT an unknown key inside a commit, validly signed" commitkey - mk testsfail '.gate_results.tests="fail"' - why='not pass' chk no "MUTANT gate_results.tests=fail, validly signed" testsfail - mk wrongrepo '.repo="paiml/other"' - why='repo is not' chk no "MUTANT wrong repo, validly signed" wrongrepo - mk edited . --post '.tree="0123456789abcdef0123456789abcdef01234567"' - why='signature does not verify' chk no "MUTANT body-edited tree with the OLD sig" edited - mk editedhost . --post '.host="evil"' - why='signature does not verify' chk no "MUTANT body-edited host with the old sig (any field is covered)" editedhost - mk wrongns . --ns other-namespace - why='signature does not verify' chk no "MUTANT wrong signing namespace" wrongns - mk unknownkey . --key "$td/other" - why='signature does not verify' chk no "MUTANT signed by a key that is not the committed batcher.pub" unknownkey - why='placeholder' chk no "MUTANT placeholder batcher.pub" good "$td/pub.placeholder" - why='signature does not verify' chk no "a test-key receipt against the COMMITTED .flow009/batcher.pub" good "$here/../.flow009/batcher.pub" - mk nofence . --nofence - why='0 flow009.receipt/1' chk no "MUTANT no flow009.receipt/1 fenced block (a plain json fence)" nofence - mk twofences . --fences 2 - why='2 flow009.receipt/1' chk no "MUTANT two flow009.receipt/1 blocks" twofences - # shape/transport layers: each row isolates ONE refusal (several later checks would also catch some of them) - sed '$d' "$td/good.body" | sed '$d' | sed '$d' | sed '$d' > "$td/unclosed.body" - jq -n --rawfile b "$td/unclosed.body" --arg h "$head" '{pull_request: {body: $b, head: {sha: $h}}}' > "$td/unclosed.event" - why='not closed' chk no "MUTANT the fenced block is never closed" unclosed - jq -n --arg h "$head" '{pull_request: {body: "```flow009.receipt/1\n```\n", head: {sha: $h}}}' > "$td/emptyblock.event" - why='empty receipt block' chk no "MUTANT an empty fenced block" emptyblock - jq -n --arg h "$head" '{pull_request: {body: "```flow009.receipt/1\nnot json\n```\n", head: {sha: $h}}}' > "$td/notjson.event" - why='not JSON' chk no "MUTANT a fenced block that is not JSON" notjson - mk numtype '.batch=7' - why='not a string' chk no "MUTANT batch is a number, validly signed" numtype - mk uphex ".tree=\"$(printf '%s' "$tree" | tr a-f A-F)\"" - why='lowercase hex' chk no "MUTANT tree in upper-case hex, validly signed" uphex - why='no committed' chk no "MUTANT no committed batcher.pub file" good "$td/absent.pub" - printf 'ssh-rsa AAAAB3Nza\n' > "$td/pub.rsa" - why='not an ssh-ed25519' chk no "MUTANT batcher.pub holds a non-ed25519 key line" good "$td/pub.rsa" - jq 'del(.pull_request.head)' "$td/good.event" > "$td/nohead2.event" - why='no PR head sha' chk no "MUTANT event carries no PR head" nohead2 - printf '#!/usr/bin/env bash\nprintf "not-a-hash\\n"\n' > "$td/junk.sh" - why='printed no sha256' chk no "MUTANT toolchain-id prints junk" good "$td/pub" "$td/junk.sh" - why='printed no sha256' chk no "MUTANT manifest-id prints junk" good "$td/pub" "$td/tcid.sh" "$td/junk.sh" - why='no toolchain-id script' chk no "MUTANT toolchain-id script absent" good "$td/pub" "$td/absent.sh" - why='no manifest-id script' chk no "MUTANT manifest-id script absent" good "$td/pub" "$td/tcid.sh" "$td/absent.sh" - why='toolchain-id script failed' chk no "MUTANT toolchain-id script fails (fail closed)" good "$td/pub" "$td/bad.sh" - why='manifest-id script failed' chk no "MUTANT manifest-id script fails (fail closed)" good "$td/pub" "$td/tcid.sh" "$td/bad.sh" - # B1 (#5944): CI runs the helpers from the trusted dir, so they only see the PR tree if handed its root. These stubs - # print their sha ONLY when argv[1] is the checkout root (the throwaway repo holds file f); a verifier that omits - # the root argument gets exit 1 here, as the real helpers' wrong-root hash would mismatch. - printf '#!/usr/bin/env bash\n[ -f "${1:-/nonexistent}/f" ] || exit 1\nprintf "%%s\\n" %s\n' "$tc" > "$td/tcroot.sh" - printf '#!/usr/bin/env bash\n[ -f "${1:-/nonexistent}/f" ] || exit 1\nprintf "%%s\\n" %s\n' "$man" > "$td/manroot.sh" - chk ok "the toolchain and manifest helpers are handed the PR checkout root" good "$td/pub" "$td/tcroot.sh" "$td/manroot.sh" - why='no event file' chk no "MUTANT missing event file (fail closed)" absent - jq -n '{push: {}}' > "$td/nopr.event" - why='no pull_request' chk no "MUTANT event with no pull_request" nopr - jq --arg h "$(printf '%040d' 0)" '.pull_request.head.sha=$h' "$td/good.event" > "$td/nohead.event" - why='not in this checkout' chk no "MUTANT PR head sha not in the checkout" nohead - # trust boundary (PMAT-1889): the key comes from the caller (FLOW009_PUBKEY = the BASE commit's copy), never the PR tree - mkdir -p "$td/r/.flow009" && cp "$td/other.pub" "$td/r/.flow009/batcher.pub" - mk selfsigned . --key "$td/other" - FP=$td/pub why='signature does not verify' chk no "MUTANT the PR tree's own batcher.pub holds the attacker key that signed the receipt; FLOW009_PUBKEY pins the real key" selfsigned .flow009/batcher.pub - FP=$td/absent.pub why='no committed' chk no "MUTANT FLOW009_PUBKEY points at a missing file (never falls back to the tree copy)" selfsigned .flow009/batcher.pub - FP=$td/absent.pub why='no committed' chk no "MUTANT FLOW009_PUBKEY missing beats a valid pubkey argument" good "$td/pub" - FP=$td/pub chk ok "the real key pinned by FLOW009_PUBKEY verifies a good receipt although the tree key is the attacker's" good .flow009/batcher.pub - # coverage= line: exactly one, the receipt's value; none when absent or non-numeric (still OK) - cov=95.1 chk ok "green path prints RECEIPT_OK=1 then exactly one coverage=95.1 line" good - out=$(cd "$td/r" && bash "$here/$(basename "${BASH_SOURCE[0]}")" paiml/aprender "$td/good.event" "$td/pub" "$td/tcid.sh" "$td/manid.sh" 2>/dev/null) - n=$((n + 1)); if [ "$(printf '%s\n' "$out" | grep -c '^coverage=')" = 1 ]; then printf 'ok row %-2s exactly one ^coverage= line on stdout\n' "$n"; else printf 'FAIL row %-2s coverage= line count\n' "$n"; red=1; fi - mk cov87 '.gate_results.coverage="87.25"' - cov=87.25 chk ok "coverage=87.25 is the receipt's own value" cov87 - mk covnan '.gate_results.coverage="n/a"' - n=$((n + 1)); out=$(cd "$td/r" && bash "$here/$(basename "${BASH_SOURCE[0]}")" paiml/aprender "$td/covnan.event" "$td/pub" "$td/tcid.sh" "$td/manid.sh" 2>/dev/null); rc=$? - if [ "$rc" = 0 ] && [ "$out" = "RECEIPT_OK=1" ]; then printf 'ok row %-2s non-numeric coverage: verify OK, no coverage= line\n' "$n"; else printf 'FAIL row %-2s non-numeric coverage (rc=%s "%s")\n' "$n" "$rc" "$out"; red=1; fi - printf '\nthis self-test ran %s checks, %s failed\n' "$n" "$red"; [ "$red" -eq 0 ] -} - -if [ "${1:-}" = "--self-test" ]; then self_test; exit $?; fi -verify "$@" diff --git a/scripts/ci_test_tier.sh b/scripts/ci_test_tier.sh index 05c345ec7a..d52c6e4cd2 100755 --- a/scripts/ci_test_tier.sh +++ b/scripts/ci_test_tier.sh @@ -49,11 +49,6 @@ # reason says why it was not reused. Only workspace-test is reused: # every other push job runs as before. # -# reuse pull_request ONLY, PMAT-5737 (FLOW-009 RC-3: the receipt is in the PR body, a merge_group has none): `--receipt-ok 1` (the caller's proof that -# scripts/ci_receipt_verify.sh printed RECEIPT_OK=1 for this tree) -> tier=reuse, -# cite=receipt:, receipt=ok, decided BEFORE any diff selection. Never a push -# (its mg-reuse arm is untouched) and never a tag (tier=full). Any other value, or no flag, -# falls through to the selection above, unchanged. # Output: KEY=VALUE lines — tier, crates (space list), targets (crate:--lib, # crate:--lib:module, crate:--bins or crate:--test:name; space list), # check_workspace (1, only under rule (i)), reason, cite (on reuse: the PR head @@ -117,7 +112,7 @@ set -euo pipefail # `unset` also drops the export attribute, so no child can inherit them. unset SELF_TEST FILTERSET FS_TARGETS SELF_TEST=0; FILTERSET=0 -EVENT=""; COMPARAND=""; DIFF_FROM=""; PR_HEAD=""; PR_CONCLUSION=""; MG_RUN=""; MG_SHA=""; MG_CONCLUSION=""; MG_GIVEN=0; RECEIPT_OK=0; REGISTRY="scripts/tree_reader_tests.txt"; ROOT="." +EVENT=""; COMPARAND=""; DIFF_FROM=""; PR_HEAD=""; PR_CONCLUSION=""; MG_RUN=""; MG_SHA=""; MG_CONCLUSION=""; MG_GIVEN=0; REGISTRY="scripts/tree_reader_tests.txt"; ROOT="." # The sibling scripts and the registry always come from the checkout this script # runs in (cwd = repo root, in ci.yml and in the case table alike); --repo-root # re-points only the GIT queries — HEAD, its parents, their trees — which is how @@ -134,7 +129,6 @@ while [ $# -gt 0 ]; do --mg-run) MG_RUN=$2; MG_GIVEN=1; shift 2 ;; --mg-sha) MG_SHA=$2; MG_GIVEN=1; shift 2 ;; --mg-conclusion) MG_CONCLUSION=$2; MG_GIVEN=1; shift 2 ;; - --receipt-ok) RECEIPT_OK=$2; shift 2 ;; --registry) REGISTRY=$2; shift 2 ;; --repo-root) ROOT=$2; shift 2 ;; --tsv) TSV=$2; shift 2 ;; @@ -146,7 +140,7 @@ while [ $# -gt 0 ]; do --filterset) FILTERSET=1; shift; if [ $# -gt 0 ]; then FS_TARGETS=$1; shift; fi ;; # same operand rules as --filterset; prints the cargo BUILD flags for the same tokens --build-flags) BUILDFLAGS=1; shift; if [ $# -gt 0 ]; then FS_TARGETS=$1; shift; fi ;; - *) printf 'usage: %s --event EVENT [--comparand REF] [--diff-from FILE] [--pr-head SHA --pr-head-conclusion C] [--mg-run ID --mg-sha SHA --mg-conclusion C] [--receipt-ok 1] [--registry FILE] [--repo-root DIR] | --filterset [TARGETS] | --tier-of-record [--tsv FILE] [--union-touched --event pull_request --comparand REF] | --self-test\n' "$0" >&2; exit 2 ;; + *) printf 'usage: %s --event EVENT [--comparand REF] [--diff-from FILE] [--pr-head SHA --pr-head-conclusion C] [--mg-run ID --mg-sha SHA --mg-conclusion C] [--registry FILE] [--repo-root DIR] | --filterset [TARGETS] | --tier-of-record [--tsv FILE] [--union-touched --event pull_request --comparand REF] | --self-test\n' "$0" >&2; exit 2 ;; esac done @@ -315,22 +309,6 @@ push_reuse_why() { decide() { local df rc=0 - # PMAT-5737 RECEIPT ARM, before any diff selection. The caller (ci/sections.yml) passes --receipt-ok 1 only - # after scripts/ci_receipt_verify.sh printed RECEIPT_OK=1 for THIS tree (flow009.receipt/1: signature, tree, patch-ids, tickets, toolchain, manifest). - # Only a pull_request takes it (FLOW-009 RC-3: the receipt lives in the PR body, a merge_group has none): a push keeps its own mg-reuse arm below, untouched, and a tag - # is full whatever a receipt says. Anything but exactly `1`, or an unreadable tree, falls through unchanged. - case "$EVENT" in - pull_request) - if [ "$RECEIPT_OK" = 1 ]; then - local rref rtree; rref=$(printenv GITHUB_REF || true) - case "$rref" in refs/tags/*) printf 'tier=full\nreason=%s of tag %s: a release tag runs the whole workspace -- a batch receipt is never honoured on a tag (PMAT-5737)\n' "$EVENT" "${rref#refs/tags/}"; return 0 ;; esac # R-RTAG - rtree=$(git -C "$ROOT" rev-parse -q --verify 'HEAD^{tree}' 2>/dev/null || true) - if [[ $rtree =~ ^[0-9a-f]{40}$ ]]; then - printf 'tier=reuse\ncite=receipt:%s\nreceipt=ok\nreason=%s: a signed batch receipt covers this exact tree (%s) -- signature, tree, patch-ids, tickets, toolchain and manifest verified by ci_receipt_verify.sh (PMAT-5737)\n' "$rtree" "$EVENT" "${rtree:0:9}" - return 0 - fi - fi ;; - esac case "$EVENT" in schedule|workflow_dispatch) printf 'tier=full\nreason=%s event: the whole workspace, every feature-gated suite -- FULL lives here (coverage-nightly, full-nightly, the pre-publish dogfood; 67-E2 decision D-1)\n' "$EVENT" ;; @@ -792,22 +770,6 @@ self_test() { row 0 " ...while apr-cli, whose registry carries an UNRESOLVED whole-lib row, still runs its WHOLE lib (fail open)" '^ALL-PRESENT$' \ contains_all "$td/real-fs.txt" "(package(apr-cli) & kind(lib))" row 0 " ...with no narrower apr-cli module atom beside it" '^ABSENT$' lacks "$td/real-fs.txt" "package(apr-cli) & kind(lib) & test" - # PMAT-5737 receipt arm: a verified signed batch receipt (ci_receipt_verify.sh -> --receipt-ok 1) makes a - # pull_request or merge_group tier=reuse, evaluated BEFORE diff selection. Never a tag, never a push. - RTREE=$(git -C "$td/q-leaf" rev-parse 'HEAD^{tree}') - row 0 "RECEIPT pull_request + --receipt-ok 1 -> tier=reuse, cite=receipt:, receipt=ok (no diff needed)" "^tier=reuse cite=receipt:$RTREE receipt=ok\$" bash -c "o=\$(bash '$T' --event pull_request --repo-root '$td/q-leaf' --receipt-ok 1); printf '%s %s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep '^cite=' <<< \"\$o\")\" \"\$(grep '^receipt=' <<< \"\$o\")\"" - row 0 "MUTANT merge_group + --receipt-ok 1 -> the receipt arm is pull_request only: no receipt line (a merge_group has no PR body)" '^NO-RECEIPT$' bash -c "o=\$(env -u MG_BASE_SHA bash '$T' --event merge_group --repo-root '$td/q-leaf' --receipt-ok 1 2>/dev/null || true); grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT" - row 0 "MUTANT --receipt-ok 0 -> the PR's own selection (quick), no receipt line" '^tier=quick NO-RECEIPT$' bash -c "o=\$(bash '$T' --event pull_request --diff-from '$td/d-scripts.txt' --receipt-ok 0); printf '%s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" - row 0 "MUTANT --receipt-ok 2 / anything but 1 -> unchanged (quick)" '^tier=quick$' bash -c "bash '$T' --event pull_request --diff-from '$td/d-scripts.txt' --receipt-ok yes | grep '^tier='" - row 0 "MUTANT no --receipt-ok flag -> unchanged (quick)" '^tier=quick NO-RECEIPT$' bash -c "o=\$(bash '$T' --event pull_request --diff-from '$td/d-scripts.txt'); printf '%s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" - row 0 "MUTANT a root-manifest diff with --receipt-ok 0 stays full (the receipt arm does not widen anything)" '^tier=full$' bash -c "bash '$T' --event pull_request --diff-from '$td/d-root.txt' --receipt-ok 0 | grep '^tier='" - row 0 "MUTANT TAG ref + pull_request + --receipt-ok 1 -> tier=full, no receipt line" '^tier=full NO-RECEIPT$' bash -c "o=\$(GITHUB_REF=refs/tags/v9.9.9 bash '$T' --event pull_request --repo-root '$td/q-leaf' --receipt-ok 1); printf '%s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" - row 0 "MUTANT TAG push + --receipt-ok 1 -> tier=full, no receipt line" '^tier=full NO-RECEIPT$' bash -c "o=\$(GITHUB_REF=refs/tags/v9.9.9 bash '$T' --event push --repo-root '$td/s-leaf' --receipt-ok 1); printf '%s %s\\n' \"\$(grep '^tier=' <<< \"\$o\" | head -n1)\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" - row 0 "MUTANT a push never takes the receipt arm: --receipt-ok 1 with no mg result -> the push's own tier, no receipt line" '^NO-RECEIPT$' bash -c "o=\$(bash '$T' --event push --repo-root '$td/s-leaf' --receipt-ok 1); grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT" - row 0 " ...and a push WITH the mg result still reuses on the mg arm, cite = the run id, no receipt line" '^tier=reuse cite=123456 NO-RECEIPT$' bash -c "o=\$(bash '$T' --event push --repo-root '$td/s-leaf' $MGARGS --receipt-ok 1); printf '%s %s %s\\n' \"\$(grep '^tier=' <<< \"\$o\")\" \"\$(grep '^cite=' <<< \"\$o\")\" \"\$(grep -q '^receipt=' <<< \"\$o\" && echo HAS-RECEIPT || echo NO-RECEIPT)\"" - sed 's|"\$RECEIPT_OK" = 1|"$RECEIPT_OK" != 1|' "$T" > "$td/mutant-receipt.sh" - row 0 " (receipt mutant file really differs from the script)" '^DIFFERS$' bash -c "if cmp -s '$T' '$td/mutant-receipt.sh'; then echo SAME; else echo DIFFERS; fi" - row 0 "MUTANT: an inverted receipt test makes --receipt-ok 0 reuse -> the rows above go RED" '^MUTANT-REUSE$' bash -c "o=\$(bash '$td/mutant-receipt.sh' --event pull_request --repo-root '$td/q-leaf' --diff-from '$td/d-scripts.txt' --receipt-ok 0); if grep -q '^receipt=ok' <<< \"\$o\"; then echo MUTANT-REUSE; else echo SURVIVED; fi" printf '\n%s checks, %s failed\n' "$n" "$red"; [ "$red" -eq 0 ] }