diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 0f3e0199..dd41dd6e 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -996,9 +996,98 @@ jobs: # CONTRIBUTING (54/412 missing) warns until the cutoff baked into the # script, then blocks. See scripts/check-docs-presence.sh. cp .standards-checkout/scripts/check-docs-presence.sh "$RUNNER_TEMP/" + # The launcher-standard currency gate (standards#960 AC4) is copied + # out here too, because this step deletes the checkout. Prefer the + # CALLER's own copy when present (self-lint: standards validating + # itself must run the tree under test, not main's copy -- a gate fix + # in flight would otherwise be judged by the version it replaces). + # Callers without the script keep the main-pinned fallback. + if [ -f scripts/check-launcher-standard-currency.sh ]; then + cp scripts/check-launcher-standard-currency.sh "$RUNNER_TEMP/" + else + cp .standards-checkout/scripts/check-launcher-standard-currency.sh "$RUNNER_TEMP/" + fi rm -rf .standards-checkout bash "$RUNNER_TEMP/check-docs-presence.sh" . + - name: Check launcher-standard currency + run: | + set -eo pipefail + # Arming policy, and the evidence it rests on: standards#991. + # + # retired-filename -> BLOCKS. A STABLE predicate: + # the retired `.a2ml` spelling of the launcher standard was + # deleted upstream on 2026-09-22 + # (standards#952) and stays deleted, so a caller that is clean + # today cannot become defective without editing the citation + # itself. Measured 2026-09-22 over EVERY clone in the estate -- + # 595 scanned, 553 carrying an origin/main. 432 reference this + # reusable workflow, but only 12 do so at a MUTABLE ref (@main), + # and a new step reaches ONLY those 12: a caller pinned at a SHA + # freezes this whole file, this step included, so it can never + # receive the step at all. The real gate was run against all 12: + # 12/12 rc=0, retired=0. Five slugs do carry the retired literal + # (tma-mark2, canonical-ums, the-nash-equilibrium, + # launch-scaffolder, trigger) and their overlap with the armed 12 + # is ZERO -- so arming this tier reds ZERO live callers. A + # known-answer positive control fired (rc=1) on three of those + # defective repos through the identical harness, so the twelve + # zeros are a real measurement and not a broken probe. + # + # stale-version -> WARNS, and does not block. A TIME-DEPENDENT + # predicate: the gate compares against its own CURRENT_VERSION, so + # every correctly-citing caller flips to defect the moment the + # standard bumps, having done nothing. A baked-in cutoff DATE does + # not cure that -- the #505 split above can use one because its + # missing-CONTRIBUTING population is static, while this population + # is regenerated at every bump. Each CURRENT_VERSION bump is a + # measure-then-arm event, not a date. + # + # Deliberately no --standard / --expect-version: the gate arrives + # from the standard's own tree, so its CURRENT_VERSION is current by + # construction. Pointing it at a caller's vendored copy of the deed + # would red that caller for citing an older canon. + rc=0 + bash "$RUNNER_TEMP/check-launcher-standard-currency.sh" --root . \ + >"$RUNNER_TEMP/launcher-currency.out" 2>&1 || rc=$? + cat "$RUNNER_TEMP/launcher-currency.out" + + # rc=2 is a usage error OR a seeded self-test mutant that survived. A broken + # invocation must never read as a clean pass, so it fails before the tier split. + if [ "$rc" -eq 2 ]; then + echo "::error::check-launcher-standard-currency exited 2: usage error, or a seeded self-test mutant survived. A broken invocation is a failure, not a pass." + exit 1 + fi + + # The tiers are split on the gate's own typed stdout lines, not on its exit + # code -- rc=1 means "some defect", and the two classes are armed differently. + retired=0 + if grep -q '^DEFECT retired-filename' "$RUNNER_TEMP/launcher-currency.out"; then retired=1; fi + stale=0 + if grep -q '^DEFECT stale-version' "$RUNNER_TEMP/launcher-currency.out"; then stale=1; fi + + if [ "$retired" -eq 1 ]; then + # ⚠ Do NOT name the retired filename literally here. This workflow + # is part of the caller's scanned tree, so the gate reads its own + # error message and reports it as a defect -- measured: two + # defects on this file, from a comment and from this very echo. + # Nothing is lost by staying general: the gate has already printed + # both the offending file:line and its own cure text above. + echo "::error::This repository names the retired .a2ml spelling of the launcher standard, deleted upstream on 2026-09-22 (standards#952). The offending file:line and the cure are printed above." + exit 1 + fi + + if [ "$stale" -eq 1 ]; then + echo "::warning::This repository cites a launcher-standard version that is no longer current. NOT blocking, by policy (standards#991): the expected version moves with the standard, so a correct citation goes stale with no action of yours. Refresh it when convenient." + fi + + # Any other non-zero rc is the gate failing in a way this wrapper does not + # classify; fail rather than guess. + if [ "$rc" -ne 0 ] && [ "$retired" -eq 0 ] && [ "$stale" -eq 0 ]; then + echo "::error::check-launcher-standard-currency exited $rc but emitted no recognised DEFECT line. Failing closed." + exit 1 + fi + wellknown: name: Well-Known (RFC 9116 + RSR) runs-on: ${{ inputs.runs-on }} diff --git a/scripts/check-launcher-standard-currency.sh b/scripts/check-launcher-standard-currency.sh index 447abe5d..7e6ae2a6 100755 --- a/scripts/check-launcher-standard-currency.sh +++ b/scripts/check-launcher-standard-currency.sh @@ -73,7 +73,15 @@ ALLOWLIST=( 'dev-notes/*' # working notes, not compliance claims '*HANDOVER*' # handover documents record prior state '*CHANGELOG*' # a changelog that cannot name the old file is useless - 'launcher/launcher-standard_praxis.deed' # the canon itself; its ;; header records its own provenance + 'launcher-standard_praxis.deed' # the canon itself, at a repo root; its ;; header records its own provenance + '*/launcher-standard_praxis.deed' # ...and the same canon VENDORED at any depth by a consumer (G2) + '*/descriptiles/META.a2ml' # descriptiles ADR carriers: dated architecture decision records + # (G3, AC1's exemption). Canonical spelling, 0-canon/CANONICAL-NAMES.adoc. + '*/6a2/META.a2ml' # ...and the deprecated '6a2' spelling of descriptiles. Both are needed: + # the estate migration is chartered separately and BOTH are on disk today + # (measured 2026-09-22: descriptiles 164 dirs, the deprecated one 770). + # NARROW BY DESIGN: matches only META.a2ml under those dirs, never + # `.machine_readable/launcher/*.launcher.a2ml`, which are live descriptors. 'scripts/check-launcher-standard-currency.sh' # this file 'scripts/tests/check-launcher-standard-currency-test.sh' ) @@ -116,9 +124,11 @@ is_allowlisted() { return 1 } -# Scan a tree. Prints one defect per line; returns 1 if any were found. +# Scan $1 recursively for retired filenames and document-version claims that +# differ from $2. Allowlisted paths and DEED grammar/schema versions are ignored. +# Prints one typed record per defect; returns 0 when clean and 1 otherwise. scan() { - local root="$1" expect="$2" defects=0 hit file lineno text rel found + local root="$1" expect="$2" defects=0 hit file lineno text rel found gap gaplc while IFS= read -r hit; do file="${hit%%:*}"; hit="${hit#*:}" @@ -132,9 +142,22 @@ scan() { defects=$((defects + 1)) fi - if [[ "$text" =~ launcher-standard(\.a2ml|_praxis\.deed)[^0-9]{0,24}v?([0-9]+\.[0-9]+\.[0-9]+) ]]; then - found="${BASH_REMATCH[2]}" - if [ "$found" != "$expect" ]; then + if [[ "$text" =~ launcher-standard(\.a2ml|_praxis\.deed)([^0-9]{0,24})v?([0-9]+\.[0-9]+\.[0-9]+) ]]; then + gap="${BASH_REMATCH[2]}" + found="${BASH_REMATCH[3]}" + # G1 -- THE TWO VERSIONS ARE NOT INTERCHANGEABLE, AND THIS GATE TRACKS ONE. + # The header above says :schema-version is the GRAMMAR (1.0.0) and + # :standard-version is the DOCUMENT (0.4.0). This test used to accept any + # number within 24 non-digit characters of the filename, so a line reading + # `launcher-standard_praxis.deed` (DEED v1.0.0). Per-app config: + # captured the GRAMMAR version and reported it as document drift -- the gate + # asking a different question than its consumer, in its own implementation. + # The gap between filename and number says which version is being named, so + # read it rather than discarding it. + gaplc="${gap,,}" + if [[ "$gaplc" == *deed* || "$gaplc" == *schema* || "$gaplc" == *grammar* ]]; then + : # a grammar/schema version, not a document-version claim -- not a defect + elif [ "$found" != "$expect" ]; then printf 'DEFECT stale-version %s:%s claims v%s, current is v%s\n' \ "$rel" "$lineno" "$found" "$expect" defects=$((defects + 1)) @@ -170,6 +193,39 @@ self_test() { # allowlist control: the worst mutant, under a dated-audit path -- must NOT be reported printf '# Compliant with %s v0.1.0\n' "$RETIRED_FILE" > "$tmp/docs/audits/old-2026-05-26.adoc" + # ---- G1/G2/G3 controls. Each of the three cures below silenced a MEASURED + # false positive on hyperpolymath/launch-scaffolder (4 of them). An exclusion + # without a mutant proving it is narrow is the vacuous-gate pattern, so the + # last fixture here MUST still fire: it is the one that proves the three + # exclusions did not also spare a true positive. + mkdir -p "$tmp/vendor" "$tmp/.machine_readable/descriptiles" \ + "$tmp/.machine_readable/6a2" "$tmp/.machine_readable/launcher" # canonical + deprecated spellings + + # G1 control: the DEED GRAMMAR version, not the document version. Must NOT fire. + printf '| Standard: `%s` (DEED v1.0.0). Per-app config:\n' "$CANONICAL_FILE" > "$tmp/g1-grammar.adoc" + + # G2 control: the canon VENDORED by a consumer, carrying its own provenance + # header naming the retired file. The allowlist used to name only this repo's + # own `launcher/` path, so a consumer got a false defect on the canon itself. + printf ';; translated from %s\n' "$RETIRED_FILE" > "$tmp/vendor/launcher-standard_praxis.deed" + + # G3 control: dated ADR carriers. AC1 exempts a dated historical record, and + # META.a2ml under a descriptiles dir is an estate-wide convention. BOTH + # spellings are seeded because both are on disk: the canonical `descriptiles` + # and the deprecated one it replaces (0-canon/CANONICAL-NAMES.adoc). Neither + # must fire. + printf 'adr = "ADR-003" ;; superseded; named %s\n' "$RETIRED_FILE" \ + > "$tmp/.machine_readable/descriptiles/META.a2ml" + printf 'adr = "ADR-003" ;; superseded; named %s\n' "$RETIRED_FILE" \ + > "$tmp/.machine_readable/6a2/META.a2ml" # the deprecated spelling of descriptiles + + # G3 NARROWNESS control -- THIS ONE MUST STILL FIRE. A live launcher descriptor + # lives under the same .machine_readable/ tree as the ADR carrier above, so a + # blanket '.machine_readable/*' exemption would have spared it. It is a real + # compliance claim against a deleted file and a dead version. + printf '# Compliant with %s v0.3.0\n' "$RETIRED_FILE" \ + > "$tmp/.machine_readable/launcher/demo-app.launcher.a2ml"; seeded=$((seeded+1)) + if [ "$seeded" -eq 0 ]; then echo "SELF-TEST ERROR: zero fixtures seeded -- the self-test is vacuous." >&2 return 2 @@ -177,14 +233,19 @@ self_test() { out="$(scan "$tmp" "$CURRENT_VERSION")" || rc=1 - local fail=0 + # Coverage counters. These are INCREMENTED BY THE CHECKS THEMSELVES so the + # summary line can never drift from the controls actually run -- the previous + # version printed a hardcoded "2 controls clean" while seven were present. + local fail=0 ndet=0 nabs=0 check_detects() { + ndet=$((ndet + 1)) if ! printf '%s' "$out" | command grep -q "$1"; then echo "SELF-TEST FAIL: mutant survived -- expected to detect: $1" >&2 fail=1 fi } check_absent() { + nabs=$((nabs + 1)) if printf '%s' "$out" | command grep -q "$1"; then echo "SELF-TEST FAIL: false positive on: $1" >&2 fail=1 @@ -197,6 +258,13 @@ self_test() { check_absent 'm3.toml.*stale-version' # m3 is current; only the filename is wrong check_absent 'clean.toml' check_absent 'docs/audits' + check_absent 'g1-grammar.adoc' # G1: grammar version is not document drift + check_absent 'vendor/launcher-standard_praxis.deed' # G2: a vendored canon is still the canon + check_absent 'descriptiles/META.a2ml' # G3: a dated ADR carrier is a historical record + check_absent '6a2/META.a2ml' # ...same, in the deprecated spelling of descriptiles + # ...and the exclusions above must NOT have spared a live descriptor: + check_detects 'retired-filename .machine_readable/launcher/demo-app.launcher.a2ml' + check_detects 'stale-version .machine_readable/launcher/demo-app.launcher.a2ml' if [ "$rc" -ne 1 ]; then echo "SELF-TEST FAIL: scan returned 0 with mutants present." >&2 @@ -209,7 +277,11 @@ self_test() { return 2 fi - printf 'self-test: %s mutants killed, 2 controls clean, OK\n' "$seeded" + if [ "$ndet" -eq 0 ] || [ "$nabs" -eq 0 ]; then + echo "SELF-TEST ERROR: a whole check tier is empty (detects=$ndet absent=$nabs)." >&2 + return 2 + fi + printf 'self-test: %s mutants seeded, %s detections asserted, %s false-positive controls clean, OK\n' "$seeded" "$ndet" "$nabs" return 0 }