diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 6ed47ae5..db637c45 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -1136,10 +1136,11 @@ jobs: - name: Parse every tracked workflow run: | - SCRIPT=".standards-dupkey/tools/policy/check-workflows-parse.sh" - if [ ! -f "$SCRIPT" ] && [ -f tools/policy/check-workflows-parse.sh ]; then + if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then SCRIPT="tools/policy/check-workflows-parse.sh" echo "Using this repository's own copy (standards self-lint)." + else + SCRIPT=".standards-dupkey/tools/policy/check-workflows-parse.sh" fi if [ ! -f "$SCRIPT" ]; then echo "::error::workflow parser gate not found in standards@main or locally" @@ -1157,15 +1158,15 @@ jobs: # were dead this way, including a CodeQL workflow with zero # successful runs in its entire lifetime. set -euo pipefail - SCRIPT=".standards-dupkey/scripts/check-workflow-duplicate-keys.sh" - # Self-hosting fallback: when THIS repository is standards, its own - # working tree already holds the script, and during a rename that copy - # is the only correct one — the pinned main checkout still has the old - # name. Preferring the fetched copy keeps every other caller on the - # canonical version. - if [ ! -f "$SCRIPT" ] && [ -f scripts/check-workflow-duplicate-keys.sh ]; then + # Standards must exercise its pull-request scripts; every consumer + # must use the canonical scripts fetched from standards@main. A + # file-existence fallback is insufficient because the old script on + # main still exists while its replacement is being tested. + if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then SCRIPT="scripts/check-workflow-duplicate-keys.sh" echo "Using this repository's own copy (standards self-lint)." + else + SCRIPT=".standards-dupkey/scripts/check-workflow-duplicate-keys.sh" fi if [ ! -f "$SCRIPT" ]; then echo "::error::duplicate-key checker not found — neither fetched from" \ @@ -1176,10 +1177,11 @@ jobs: # The lockfile gate below runs in a consumer checkout, where the # standards helper is not present. Preserve the canonical helper # before removing this sparse standards checkout. - LOCK_SCRIPT=".standards-dupkey/scripts/update-actions-lock.sh" - if [ ! -f "$LOCK_SCRIPT" ] && [ -f scripts/update-actions-lock.sh ]; then + if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then LOCK_SCRIPT="scripts/update-actions-lock.sh" echo "Using this repository's own actions-lock verifier (standards self-lint)." + else + LOCK_SCRIPT=".standards-dupkey/scripts/update-actions-lock.sh" fi if [ ! -f "$LOCK_SCRIPT" ]; then echo "::error::actions-lock verifier not found — neither fetched from" \ @@ -1377,13 +1379,22 @@ jobs: - name: Exemption ratchet run: | set -euo pipefail + # Bootstrap rule: standards must exercise the script from the pull + # request being tested. Consumers must use the trusted copy from + # standards@main and cannot substitute a same-named local file. + # This mirrors the sibling debt-ratchet job below. + if [ "${{ github.repository }}" = "hyperpolymath/standards" ]; then + cp scripts/check-exemption-ratchet.sh \ + scripts/count-ledger-entries.sh "$RUNNER_TEMP/" + else + cp .standards-checkout/scripts/check-exemption-ratchet.sh \ + .standards-checkout/scripts/count-ledger-entries.sh "$RUNNER_TEMP/" + fi # Stage the script OUT of the scanned tree and delete the checkout, # so the ratchet only ever reads the CALLER's ledgers — standards has # ledgers of its own and they are not this repository's. # Both files: the ratchet calls count-ledger-entries.sh as a sibling, # and it has no fallback if the counter is missing — deliberately. - cp .standards-checkout/scripts/check-exemption-ratchet.sh \ - .standards-checkout/scripts/count-ledger-entries.sh "$RUNNER_TEMP/" rm -rf .standards-checkout bash "$RUNNER_TEMP/check-exemption-ratchet.sh" \ "${{ github.event.pull_request.base.sha }}" diff --git a/docs/EXEMPTION-MECHANISMS.adoc b/docs/EXEMPTION-MECHANISMS.adoc index fdf03401..524fe876 100644 --- a/docs/EXEMPTION-MECHANISMS.adoc +++ b/docs/EXEMPTION-MECHANISMS.adoc @@ -39,6 +39,15 @@ The two have their own ratchets, and they answer different questions: * `check-exemption-ratchet.sh` — "did you quietly excuse more?" * `check-debt-ratchet.sh` — "did the number go up?" +The exemption ratchet is adoption-safe. Anonymous baseline entries that +already exist at the comparison base are grandfathered only while they remain +identical as complete JSON values. Adding an anonymous entry, adding a duplicate +copy, or changing any field on one fails; adding `note` or `tracking_issue`, or +deleting the entry, is debt reduction and passes. This lets a repository adopt +the gate without making unrelated work depend on an immediate historical +cleanup, while ensuring the anonymous population can never grow or change +silently. + Layer 4b below (the `.claude/CLAUDE.md` exemption tables, which carry a *count* and an *unblock condition*) is the case where the two got conflated. Those tables are debt wearing an exemption's clothing, and they migrate to diff --git a/scripts/check-exemption-ratchet.sh b/scripts/check-exemption-ratchet.sh index bc88238d..ae4afdfc 100755 --- a/scripts/check-exemption-ratchet.sh +++ b/scripts/check-exemption-ratchet.sh @@ -27,9 +27,11 @@ # 1. NO SILENT GROWTH. An exemption ledger may lose entries freely. # Gaining entries requires an explicit, reviewed # declaration in the commit message. -# 2. NO ANONYMOUS ENTRIES. Every .hypatia-baseline.json entry must carry a -# `note` or a `tracking_issue`. "What is this?" -# must be answerable without archaeology. +# 2. NO NEW ANONYMOUS DEBT. Every new or changed .hypatia-baseline.json +# entry must carry a `note` or a `tracking_issue`. +# Legacy anonymous entries are tolerated only +# while identical as complete JSON values; +# touching one requires documenting it. # 3. NO WILDCARD LEDGERS. A `**` pattern in a banned-language ledger # silently absorbs files added later, which turns # a migration ledger into a permanent blind spot. @@ -148,13 +150,65 @@ for path in "${LEDGERS[@]}"; do fi done -# 2. No anonymous baseline entries. +# 2. No new or changed anonymous baseline entries. +# +# This is deliberately a ratchet, not an instantaneous cleanliness gate. A +# repository may already have anonymous legacy entries when it adopts the +# shared workflow. Failing every unrelated PR until all of that historic debt +# is documented makes the gate impossible to introduce and encourages blanket +# bypasses. Instead, compare complete JSON values as a multiset: +# +# * an unchanged anonymous value is grandfathered; +# * adding an anonymous value fails; +# * changing any field on an anonymous value fails (the old value vanished +# and a new undocumented value appeared); +# * adding a note/tracking_issue, or deleting an entry, is debt reduction and +# passes; +# * duplicate values are counted, so appending a second identical anonymous +# entry cannot hide behind one grandfathered copy. if [ -f .hypatia-baseline.json ]; then - anon="$(jq '[.[] | select((has("note")|not) and (has("tracking_issue")|not))] | length' \ - .hypatia-baseline.json 2>/dev/null || echo 0)" + base_baseline="$(mktemp)" + trap 'rm -f "$base_baseline"' EXIT + if git cat-file -e "${BASE_REF}:.hypatia-baseline.json" 2>/dev/null; then + git show "${BASE_REF}:.hypatia-baseline.json" > "$base_baseline" + else + printf '[]\n' > "$base_baseline" + fi + + anon_delta="$(jq -n \ + --slurpfile before "$base_baseline" \ + --slurpfile after .hypatia-baseline.json ' + def anonymous: + (has("note") | not) and (has("tracking_issue") | not); + + ($before[0]) as $base + | ($after[0]) as $head + | if (($base | type) != "array" or ($head | type) != "array") then + error(".hypatia-baseline.json must contain a JSON array at base and HEAD") + else + ([ $base[] | select(anonymous) ] + | group_by(.) + | map({entry: .[0], count: length})) as $base_groups + | ([ $head[] | select(anonymous) ] + | group_by(.) + | map({entry: .[0], count: length})) as $head_groups + | [ $head_groups[] as $new + | (($base_groups + | map(select(.entry == $new.entry) | .count) + | first) // 0) as $old_count + | select($new.count > $old_count) + | {entry: $new.entry, added: ($new.count - $old_count)} ] + end + ')" + rm -f "$base_baseline" + trap - EXIT + + anon="$(printf '%s\n' "$anon_delta" | jq '[.[].added] | add // 0')" if [ "${anon:-0}" -gt 0 ]; then - note "ANONYMOUS .hypatia-baseline.json: ${anon} entr(y|ies) carry neither a note nor a tracking_issue" - note " Every exemption must say what it is. Add \`note\` explaining" + note "ANONYMOUS .hypatia-baseline.json: ${anon} new or changed entr(y|ies) carry neither a note nor a tracking_issue" + printf '%s\n' "$anon_delta" | jq -r \ + '.[] | " \(.added)x \(.entry | tojson)"' + note " Every exemption touched now must say what it is. Add \`note\` explaining" note " what the finding actually is, or \`tracking_issue\` naming the" note " work that discharges it." fail=1 diff --git a/scripts/tests/actions-lock-update-test.sh b/scripts/tests/actions-lock-update-test.sh index 8ca997b5..0ceeb6ff 100755 --- a/scripts/tests/actions-lock-update-test.sh +++ b/scripts/tests/actions-lock-update-test.sh @@ -46,6 +46,14 @@ if [ "${2:-}" = "--verify-local" ]; then printf '%s\n' 'not valid JSON' exit 0 ;; + valid-advisory) + printf '%s\n' '{"valid":true,"findings":[{"workflow":".github/workflows/ci.yml","category":"sha-as-ref","severity":"warning","dependency":"actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"}]}' + exit 1 + ;; + invalid-empty) + printf '%s\n' '{"valid":false,"findings":[]}' + exit 1 + ;; *) printf '%s\n' '{"valid":true,"findings":[]}' exit @@ -124,6 +132,19 @@ if FAKE_VERIFY_FINDING=reusable-non-stale GH_BIN="$WORK/bin/fake-gh" \ fi echo "PASS: non-stale reusable-workflow finding remains blocking" +advisory_output="$(FAKE_VERIFY_FINDING=valid-advisory GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows)" +printf '%s\n' "$advisory_output" | grep -q '"category":"sha-as-ref"' +printf '%s\n' "$advisory_output" | grep -q 'valid with advisory finding' +echo "PASS: authoritative valid result accepts but preserves advisory findings" + +if FAKE_VERIFY_FINDING=invalid-empty GH_BIN="$WORK/bin/fake-gh" \ + bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then + echo "FAIL: contradictory invalid result with no findings was accepted" >&2 + exit 1 +fi +echo "PASS: invalid result with no explainable findings fails closed" + if FAKE_VERIFY_FINDING=malformed-success GH_BIN="$WORK/bin/fake-gh" \ bash "$UPDATE" --verify-local .github/workflows >/dev/null 2>&1; then echo "FAIL: malformed successful verifier output was accepted" >&2 diff --git a/scripts/tests/exemption-ratchet-test.sh b/scripts/tests/exemption-ratchet-test.sh index 90b66fca..ac8def13 100755 --- a/scripts/tests/exemption-ratchet-test.sh +++ b/scripts/tests/exemption-ratchet-test.sh @@ -14,7 +14,22 @@ SCRIPT="$(cd "$(dirname "$0")/.." && pwd)/check-exemption-ratchet.sh" WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT cd "$WORK"; git init -q .; git config user.email t@example.com; git config user.name T -printf '[{"severity":"high","rule_module":"m","type":"t","file":"a.rs","note":"why"}]\n' > .hypatia-baseline.json +# Reproduce the adoption case that exposed the defect: a repository already +# has many anonymous legacy entries. Unrelated work must not be blocked merely +# because those values predate the ratchet; only a new or changed anonymous +# value is a violation. +jq -n '[range(0; 44) | { + severity: "medium", + rule_module: "legacy", + type: ("legacy-" + tostring), + file: ("legacy-" + tostring + ".rs") +}] + [{ + severity: "high", + rule_module: "m", + type: "t", + file: "a.rs", + note: "why" +}]' > .hypatia-baseline.json printf 'cicd_rules/banned_language_file:src/A.res\n' > .hypatia-ignore git add -A; git commit -q -m base BASE="$(git rev-parse HEAD)" @@ -27,7 +42,7 @@ expect() { # expect