Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 24 additions & 13 deletions .github/workflows/governance-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" \
Expand All @@ -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" \
Expand Down Expand Up @@ -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 }}"
Expand Down
9 changes: 9 additions & 0 deletions docs/EXEMPTION-MECHANISMS.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 62 additions & 8 deletions scripts/check-exemption-ratchet.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions scripts/tests/actions-lock-update-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
52 changes: 50 additions & 2 deletions scripts/tests/exemption-ratchet-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -27,7 +42,7 @@ expect() { # expect <wanted-exit> <label>
else fail=$((fail+1)); echo " FAIL $label (wanted exit $want, got $got)"; fi
}

expect 0 "unchanged ledgers pass"
expect 0 "44 unchanged anonymous legacy entries pass"

# jq, not an interpreter: estate policy bans Python, and the JS runtime has
# already moved once. jq is what the rest of the estate's CI already uses.
Expand All @@ -48,6 +63,39 @@ git commit -aqm "anonymous
Ratchet-exception: .hypatia-baseline.json — declared"
expect 1 "anonymous entry fails even when growth is declared"

# Multiset semantics matter: one grandfathered value must not license an
# unlimited number of identical copies.
git checkout -q "$BASE" -- .hypatia-baseline.json .hypatia-ignore
jq '. + [.[0]]' .hypatia-baseline.json > .baseline.tmp \
&& mv .baseline.tmp .hypatia-baseline.json
git commit -aqm "duplicate anonymous legacy entry

Ratchet-exception: .hypatia-baseline.json — duplicate-control fixture"
expect 1 "appending a duplicate anonymous entry fails"

# A same-size rewrite is still new anonymous debt: comparing counts alone
# misses it. Full-value comparison must see the modified value.
git checkout -q "$BASE" -- .hypatia-baseline.json .hypatia-ignore
jq '.[0].severity = "high"' .hypatia-baseline.json > .baseline.tmp \
&& mv .baseline.tmp .hypatia-baseline.json
git commit -aqm "modify anonymous legacy entry"
expect 1 "modified anonymous entry fails without ledger growth"

# Documentation is debt reduction. Although the JSON value changed, its new
# form is no longer anonymous and therefore must pass.
git checkout -q "$BASE" -- .hypatia-baseline.json .hypatia-ignore
jq '.[0].note = "legacy finding documented when touched"' \
.hypatia-baseline.json > .baseline.tmp && mv .baseline.tmp .hypatia-baseline.json
git commit -aqm "document legacy entry"
expect 0 "adding documentation to a legacy anonymous entry passes"

# Removing grandfathered debt is always allowed.
git checkout -q "$BASE" -- .hypatia-baseline.json .hypatia-ignore
jq 'del(.[0])' .hypatia-baseline.json > .baseline.tmp \
&& mv .baseline.tmp .hypatia-baseline.json
git commit -aqm "remove legacy entry"
expect 0 "deleting a legacy anonymous entry passes"

git checkout -q "$BASE" -- .hypatia-baseline.json
printf 'cicd_rules/banned_language_file:src/**\n' > .hypatia-ignore
git commit -aqm "wildcard"
Expand Down
19 changes: 17 additions & 2 deletions scripts/update-actions-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,36 @@ verify_lock_coverage() {
[[ "$status" -ne 0 ]] && return "$status"
return 1
fi
if printf '%s' "$result" | jq -e '.valid == true and (.findings | length == 0)' >/dev/null; then
# The authoritative validity bit and the process exit are not equivalent.
# gh-actions-lock v0.1.6 exits 1 for advisory findings such as `sha-as-ref`
# even while reporting `"valid": true`. Treating that advisory exit as an
# invalid lock makes every inline-SHA repository permanently red. Preserve
# the findings in the log, but accept the lock exactly when the tool says it
# is valid.
if printf '%s' "$result" | jq -e '.valid == true' >/dev/null; then
if printf '%s' "$result" | jq -e '.findings | length > 0' >/dev/null; then
printf '%s\n' "$result"
echo "actions-lock: valid with advisory finding(s)"
fi
return 0
fi

remaining=0
accepted=0
while IFS=$'\t' read -r category workflow dependency; do
if [[ "$category" = stale ]] &&
workflow_references_reusable_dependency "$workflow" "$dependency"; then
echo "Accepted reusable-workflow lock coverage: $workflow -> $dependency"
accepted=$((accepted + 1))
else
remaining=$((remaining + 1))
fi
done < <(printf '%s' "$result" | jq -r '.findings[] | [.category, .workflow, .dependency] | @tsv')

if [[ "$remaining" -ne 0 ]]; then
# `valid:false` with no findings is contradictory and cannot be explained by
# the one known reusable-workflow false positive. Fail closed rather than
# turning an empty/malformed diagnostic into approval.
if [[ "$remaining" -ne 0 || "$accepted" -eq 0 ]]; then
printf '%s\n' "$result"
return 1
fi
Expand Down
Loading