Skip to content

Commit dbfd661

Browse files
feat(governance): promote the docs + package-policy gates from theatre to real (#505) (#513)
Promotes the two `governance-reusable.yml` jobs that **could not fail** into real gates — tasks 1, 2 and 3 of #505. ## Blast radius was measured first, not guessed Over the **412 real repo-root callers** of `governance-reusable` in the estate checkout (1,106 raw grep hits reduce to 412 once nested monorepo subdirs — whose `.github/workflows` GitHub never reads — are excluded): | check | missing | decision | |---|---|---| | README | **0 / 412** | **blocking now** — arming reds nobody | | LICENSE | **0 / 412** | **blocking now** — arming reds nobody | | CONTRIBUTING | 54 / 412 (13%) | warn → **blocking from 2026-08-21** | | no Guix/Nix packaging | 70 / 412 (17%) | warn → **blocking from 2026-08-21** | **Verified against all 412 callers with the final scripts: 0 red today on both gates.** The grace window buys time without manufacturing red noise now — and it is a *real date that flips itself*, not a flag defaulting to off (which would be the theatre this replaces). **94 unique repos** must land a `CONTRIBUTING` and/or packaging file before the cutoff. Full list attached as a comment below. ## What changed **`scripts/check-docs-presence.sh`** (new) — replaces the `::warning::`-only step. Split gate: README+LICENSE fail immediately, CONTRIBUTING warns until the cutoff. Critically, it **never prints a pass claim while a required file is absent** — grace mode prints `NOT YET ENFORCED`, not a green tick. **`scripts/check-package-policy.sh`** (new) — replaces the job that ended in an unconditional `✅ Package policy check passed`. Two honesty fixes beyond adding `exit 1`: - Predicate tightened from *"any `*.scm` anywhere"* (a stray Guile source file satisfied it) to a genuine packaging artefact (`guix.scm`/`manifest.scm`/`channels.scm`/`.guix-channel`). Tightening moved only **13** repos, so honesty was nearly free. - Vendored trees (`node_modules/`, `deps/`, `.lake/`, `vendor/`, `.git/`) are pruned, so a dependency can't satisfy the policy on the caller's behalf. - The `git diff HEAD~1` lockfile branch is **dropped, not stubbed**: it never resolved under `ref: github.sha` without `fetch-depth`, so it was inert. hypatia `cicd_rules/nodejs_detected` is the real rule. Broadening it beyond `package-lock.json` is follow-up, noted below. **Redundant trufflehog removed** (#505 task 3). It was `continue-on-error: true` so it never gated, and it duplicated the real gitleaks gate from #500. `secret-scanner-reusable.yml`'s header *already* claimed "Trufflehog removed as redundant" — this reconciles the estate with itself. Removing a never-failing step cannot newly-red any caller. **Both gates refuse to be disarmed.** A malformed cutoff would make the date comparison pick the grace branch forever — silently restoring the fake gate. Both scripts validate their own cutoff format and hard-fail on garbage. ## A real bug, found only by running against real repos `find … | head -1` under `set -o pipefail` is a **SIGPIPE race**: `head` exits after line 1, `find` keeps writing, takes SIGPIPE, and `pipefail` propagates it — aborting under `set -e`. It only bites on trees large enough that `find` is still running, so **all 20 unit fixtures passed while two real estate repos red intermittently for no policy reason**. Fixed, with a regression test that was itself red-teamed: it *fails* against the buggy form and passes against the fix. (The first version of that test was a false-green — 3,000 non-matching files don't trigger it, because `find` emits one line; the fixture now creates 4,000 *matching* paths.) ## Verification `just governance-gates-test` → **21 cases, all green**. Drives both gates on **both sides of the cutoff** (`DOCS_TODAY` / `PKG_TODAY` seams), plus alternate extensions, inclusive-cutoff boundary, vendored-tree pruning, stray-`.scm` rejection, malformed-cutoff refusal, and the SIGPIPE regression. This matters because **#505 notes standards CI does not exercise the reusable for SHA-pinned callers** — watching this repo go green proves nothing about them. Note however that **396 callers pin `@main`**, so they pick this up *immediately on merge*; the grace window is what makes that safe. ## ⚠️ Reviewer note — the plumbing proof and the expected red standards calls this reusable **on its own PRs** (`governance.yml` → `./.github/workflows/governance-reusable.yml`), and the script checkout is pinned `ref: main`. The new scripts are not on `main` until this merges, so the final commit's governance run **will red for that reason alone, not because the change is broken**. To prove the plumbing (sparse-checkout → `cp` to `$RUNNER_TEMP` → `rm -rf .standards-checkout` → run) actually works end-to-end, this branch temporarily points `ref:` at itself; that run is the evidence, and the `ref:` is reverted to `main` before merge. See the commit history. ## Rollback Revert this PR, or re-pin callers to the previous `governance-reusable` SHA. The two new scripts are additive — nothing else calls them. ## Follow-up (not in scope here) - Broaden hypatia `cicd_rules/nodejs_detected` beyond `package-lock.json` (yarn/Gemfile/Pipfile/poetry lockfiles), recovering the coverage the inert branch nominally claimed. - The 94-repo remediation list before 2026-08-21. Refs #505, #500 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 614451b commit dbfd661

5 files changed

Lines changed: 463 additions & 34 deletions

File tree

.github/workflows/governance-reusable.yml

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -535,22 +535,29 @@ jobs:
535535
# governance jobs on every PR estate-wide. github.sha resolves to the
536536
# same merge commit but is always fetchable.
537537
ref: ${{ github.sha }}
538+
539+
# A reusable workflow only auto-checks-out its own YAML, not sibling
540+
# scripts. Sparse-check-out standards' scripts/ to get
541+
# check-package-policy.sh, mirroring the baseline-filter job above.
542+
- name: Check out standards for the package-policy gate
543+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
544+
with:
545+
repository: hyperpolymath/standards
546+
ref: main
547+
path: .standards-checkout
548+
sparse-checkout: |
549+
scripts
550+
sparse-checkout-cone-mode: false
551+
538552
- name: Enforce Guix primary / Nix fallback
539553
run: |
540-
HAS_GUIX=$(find . -name "*.scm" -o -name ".guix-channel" -o -name "guix.scm" 2>/dev/null | head -1)
541-
HAS_NIX=$(find . -name "*.nix" 2>/dev/null | head -1)
542-
NEW_LOCKS=$(git diff --name-only --diff-filter=A HEAD~1 2>/dev/null | grep -E 'package-lock\.json|yarn\.lock|Gemfile\.lock|Pipfile\.lock|poetry\.lock' || true)
543-
if [ -n "$NEW_LOCKS" ]; then
544-
echo "⚠️ Lock files detected. Prefer Guix manifests for reproducibility."
545-
fi
546-
if [ -n "$HAS_GUIX" ]; then
547-
echo "✅ Guix package management detected (primary)"
548-
elif [ -n "$HAS_NIX" ]; then
549-
echo "✅ Nix package management detected (fallback)"
550-
else
551-
echo "ℹ️ Consider adding guix.scm or flake.nix for reproducible builds"
552-
fi
553-
echo "✅ Package policy check passed"
554+
# Move the checker OUT of the scanned tree and delete the standards
555+
# checkout before scanning: the gate walks the whole caller tree, so
556+
# a packaging file shipped inside .standards-checkout/ would satisfy
557+
# the policy on the caller's behalf (same trap as the baseline job).
558+
cp .standards-checkout/scripts/check-package-policy.sh "$RUNNER_TEMP/"
559+
rm -rf .standards-checkout
560+
bash "$RUNNER_TEMP/check-package-policy.sh" .
554561
555562
security-policy:
556563
name: Security policy checks
@@ -813,17 +820,14 @@ jobs:
813820
- name: Check file permissions
814821
run: |
815822
find . -type f -perm /111 -name "*.sh" | head -10 || true
816-
- name: Check for secrets
817-
uses: trufflesecurity/trufflehog@27b0417c16317ca9a472a9a8092acce143b49c55 # v3.95.9
818-
with:
819-
path: ./
820-
base: ${{ github.event.pull_request.base.sha || github.event.before }}
821-
head: ${{ github.sha }}
822-
# by-design: trufflehog is a best-effort advisory scan; a scanner
823-
# diff/range hiccup must not fail the whole governance gate. The
824-
# blocking secret check is the inline grep in the security job.
825-
# (Tooling Version Integrity Rule 4 — documented soft-gate.)
826-
continue-on-error: true
823+
# NOTE: the advisory trufflehog scan that used to sit here was removed
824+
# (standards#505). It was `continue-on-error: true`, so it never gated,
825+
# and it duplicated the estate's real secret gate — gitleaks in
826+
# secret-scanner-reusable.yml, which became genuinely blocking in #500
827+
# (pinned + checksummed binary, `--exit-code 1`, no continue-on-error).
828+
# secret-scanner-reusable's own header already claimed "Trufflehog removed
829+
# as redundant"; keeping it here contradicted that across the same estate.
830+
# Removing a never-failing step cannot newly-red any caller.
827831
- name: Check TODO/FIXME
828832
run: |
829833
echo "=== TODOs ==="
@@ -836,17 +840,27 @@ jobs:
836840
# advisory: formatting hygiene is reported from the reusable estate
837841
# bundle; repos opt into blocking formatter checks locally when ready.
838842
continue-on-error: true
843+
# Sparse-check-out standards' scripts/ for the docs gate (a reusable
844+
# workflow only auto-checks-out its own YAML, not sibling scripts).
845+
- name: Check out standards for the docs gate
846+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
847+
with:
848+
repository: hyperpolymath/standards
849+
ref: main
850+
path: .standards-checkout
851+
sparse-checkout: |
852+
scripts
853+
sparse-checkout-cone-mode: false
854+
839855
- name: Check documentation
840856
run: |
841-
MISSING=""
842-
[ ! -f "README.md" ] && [ ! -f "README.adoc" ] && MISSING="$MISSING README"
843-
[ ! -f "LICENSE" ] && [ ! -f "LICENSE.txt" ] && [ ! -f "LICENSE.md" ] && MISSING="$MISSING LICENSE"
844-
[ ! -f "CONTRIBUTING.md" ] && [ ! -f "CONTRIBUTING.adoc" ] && MISSING="$MISSING CONTRIBUTING"
845-
if [ -n "$MISSING" ]; then
846-
echo "::warning::Missing docs:$MISSING"
847-
else
848-
echo "✅ Core documentation present"
849-
fi
857+
# Split gate (standards#505): README + LICENSE block immediately —
858+
# measured 0/412 callers missing either, so arming them reds nobody.
859+
# CONTRIBUTING (54/412 missing) warns until the cutoff baked into the
860+
# script, then blocks. See scripts/check-docs-presence.sh.
861+
cp .standards-checkout/scripts/check-docs-presence.sh "$RUNNER_TEMP/"
862+
rm -rf .standards-checkout
863+
bash "$RUNNER_TEMP/check-docs-presence.sh" .
850864
851865
wellknown:
852866
name: Well-Known (RFC 9116 + RSR)

Justfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ automation-test:
5353
gates-test:
5454
@bash scripts/tests/wave8-gates-test.sh
5555

56+
# standards#505 gate-promotion regression: docs + package-policy gates can fail,
57+
# and their grace cutoffs actually flip (tested from both sides of the date)
58+
governance-gates-test:
59+
@bash scripts/tests/governance-gates-505-test.sh
60+
5661
# Structural validation of the Mustfile contract (severity + run/verification per check)
5762
mustfile-check path=".machine_readable/contractiles/must/Mustfile.a2ml":
5863
@bash scripts/check-mustfile-structure.sh "{{path}}"

scripts/check-docs-presence.sh

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
4+
#
5+
# check-docs-presence.sh — gate on core repository documentation.
6+
#
7+
# Replaces the `::warning::`-only "Check documentation" step in
8+
# governance-reusable.yml, which could not fail over any input (standards#505).
9+
#
10+
# SPLIT GATE (wave8 doctrine: promote honestly, no manufactured red noise).
11+
# Blast radius measured 2026-07-21 over the 412 real repo-root callers of
12+
# governance-reusable in the local estate checkout:
13+
#
14+
# README 0/412 missing -> BLOCKING NOW. Arming reds nobody.
15+
# LICENSE 0/412 missing -> BLOCKING NOW. Arming reds nobody.
16+
# CONTRIBUTING 54/412 missing -> WARN until the cutoff, then BLOCKING.
17+
#
18+
# The CONTRIBUTING cutoff is a real date that flips itself. It is not a flag
19+
# defaulting to off: on the cutoff date this check starts failing with no
20+
# further edit to this file. That is the difference between a scheduled gate
21+
# and theatre.
22+
#
23+
# Accepted filenames follow the estate documentation policy (AsciiDoc is the
24+
# default; GitHub-required community-health files stay Markdown):
25+
# README.adoc | README.md
26+
# LICENSE | LICENSE.txt | LICENSE.md
27+
# CONTRIBUTING.md | CONTRIBUTING.adoc
28+
#
29+
# Usage: check-docs-presence.sh [repo-root]
30+
#
31+
# Environment (test seams — the shipped policy is the default in each case):
32+
# ENFORCE_CONTRIBUTING_FROM YYYY-MM-DD; enforcement begins ON this date.
33+
# DOCS_TODAY YYYY-MM-DD; overrides "now" so the pre-cutoff
34+
# and post-cutoff branches are both testable.
35+
#
36+
# Exit: 0 = pass (or in-grace warning), 1 = policy failure or bad configuration.
37+
38+
set -euo pipefail
39+
40+
ROOT="${1:-.}"
41+
42+
# Enforcement begins ON this date (inclusive), estate-wide, for CONTRIBUTING.
43+
ENFORCE_CONTRIBUTING_FROM="${ENFORCE_CONTRIBUTING_FROM:-2026-08-21}"
44+
TODAY="${DOCS_TODAY:-$(date -u +%Y-%m-%d)}"
45+
46+
# A malformed date would make the lexicographic comparison below silently
47+
# choose the grace branch forever — i.e. it would turn this gate back into the
48+
# fake gate it replaces. Refuse to run rather than run un-armed.
49+
valid_date() {
50+
case "$1" in
51+
[0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) return 0 ;;
52+
*) return 1 ;;
53+
esac
54+
}
55+
require_date() {
56+
local name="$1" value="$2"
57+
if ! valid_date "$value"; then
58+
echo "::error::check-docs-presence: $name='$value' is not YYYY-MM-DD."
59+
echo "Refusing to run: an unparseable cutoff would silently disarm this gate."
60+
exit 1
61+
fi
62+
}
63+
require_date ENFORCE_CONTRIBUTING_FROM "$ENFORCE_CONTRIBUTING_FROM"
64+
require_date DOCS_TODAY "$TODAY"
65+
66+
if [ ! -d "$ROOT" ]; then
67+
echo "::error::check-docs-presence: '$ROOT' is not a directory."
68+
exit 1
69+
fi
70+
71+
# have <name>... -> 0 if any of the candidate filenames exists at the root.
72+
have() {
73+
local f
74+
for f in "$@"; do
75+
[ -f "$ROOT/$f" ] && return 0
76+
done
77+
return 1
78+
}
79+
80+
blocking_missing=""
81+
grace_missing=""
82+
83+
have README.adoc README.md || blocking_missing="$blocking_missing README"
84+
have LICENSE LICENSE.txt LICENSE.md || blocking_missing="$blocking_missing LICENSE"
85+
86+
if ! have CONTRIBUTING.md CONTRIBUTING.adoc; then
87+
# String comparison is sound here: YYYY-MM-DD sorts chronologically, and both
88+
# operands are format-validated above.
89+
if [[ "$TODAY" < "$ENFORCE_CONTRIBUTING_FROM" ]]; then
90+
grace_missing="CONTRIBUTING"
91+
else
92+
blocking_missing="$blocking_missing CONTRIBUTING"
93+
fi
94+
fi
95+
96+
if [ -n "$grace_missing" ]; then
97+
echo "::warning::Missing docs (grace period):$grace_missing — this becomes a" \
98+
"BLOCKING failure on $ENFORCE_CONTRIBUTING_FROM (today is $TODAY)."
99+
fi
100+
101+
if [ -n "$blocking_missing" ]; then
102+
echo "::error::Missing required documentation:$blocking_missing"
103+
echo
104+
echo "Required at the repository root (either extension where two are listed):"
105+
echo " README.adoc (or README.md)"
106+
echo " LICENSE (or LICENSE.txt / LICENSE.md)"
107+
echo " CONTRIBUTING.md (or CONTRIBUTING.adoc)"
108+
echo
109+
echo "Estate policy: docs are AsciiDoc by default; see hyperpolymath/standards."
110+
exit 1
111+
fi
112+
113+
if [ -n "$grace_missing" ]; then
114+
# Never claim a pass while a required file is absent. The job is green only
115+
# because the cutoff has not arrived — say exactly that.
116+
echo "NOT YET ENFORCED: CONTRIBUTING is missing but inside the grace window."
117+
exit 0
118+
fi
119+
120+
echo "✅ Core documentation present (README, LICENSE, CONTRIBUTING)"

scripts/check-package-policy.sh

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: MPL-2.0
3+
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell
4+
#
5+
# check-package-policy.sh — gate on the Guix-primary / Nix-fallback policy.
6+
#
7+
# Replaces the echo-only "Enforce Guix primary / Nix fallback" step in
8+
# governance-reusable.yml, whose every branch echoed and which terminated with
9+
# an unconditional `✅ Package policy check passed` (standards#505). It could
10+
# not detect a violation, and it claimed a pass over any input.
11+
#
12+
# POLICY (CLAUDE.md, "Package Management"): Guix primary (guix.scm), Nix
13+
# fallback (flake.nix). A repo satisfying neither is the violation.
14+
#
15+
# PREDICATE — deliberately tightened. The previous step accepted *any* `*.scm`
16+
# anywhere in the tree as proof of "Guix package management detected", which a
17+
# stray Guile source file satisfies without any packaging existing. This script
18+
# requires a genuine packaging artefact (guix.scm / manifest.scm / channels.scm
19+
# / .guix-channel) and prunes vendored trees so a dependency's file cannot
20+
# satisfy the policy on the repo's behalf. Measured 2026-07-21 over the 412 real
21+
# repo-root callers, tightening moved only 13 repos (57 -> 70 failing), so the
22+
# honest predicate is nearly free.
23+
#
24+
# GRACE WINDOW: 70/412 callers (17%) currently satisfy neither. They warn until
25+
# the cutoff, then fail. The cutoff is a real date that flips itself with no
26+
# further edit to this file.
27+
#
28+
# NOTE ON LOCKFILES: the replaced step also grepped `git diff HEAD~1` for
29+
# package-lock.json / yarn.lock / Gemfile.lock / Pipfile.lock / poetry.lock.
30+
# That branch was inert — governance checks out at `ref: github.sha` without
31+
# `fetch-depth: 0`, so `HEAD~1` does not resolve and the command was swallowed
32+
# by `2>/dev/null` / `|| true`. It is dropped here rather than kept as a stub:
33+
# the blocking rule for lockfiles is hypatia `cicd_rules/nodejs_detected`.
34+
# Broadening that rule beyond package-lock.json is tracked as follow-up.
35+
#
36+
# Usage: check-package-policy.sh [repo-root]
37+
#
38+
# Environment (test seams — the shipped policy is the default in each case):
39+
# ENFORCE_PACKAGE_POLICY_FROM YYYY-MM-DD; enforcement begins ON this date.
40+
# PKG_TODAY YYYY-MM-DD; overrides "now" so the pre-cutoff
41+
# and post-cutoff branches are both testable.
42+
#
43+
# Exit: 0 = pass (or in-grace warning), 1 = policy failure or bad configuration.
44+
45+
set -euo pipefail
46+
47+
ROOT="${1:-.}"
48+
49+
ENFORCE_PACKAGE_POLICY_FROM="${ENFORCE_PACKAGE_POLICY_FROM:-2026-08-21}"
50+
TODAY="${PKG_TODAY:-$(date -u +%Y-%m-%d)}"
51+
52+
# An unparseable cutoff would make the comparison below pick the grace branch
53+
# forever, restoring the fake gate this script replaces. Refuse to run.
54+
valid_date() {
55+
case "$1" in
56+
[0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) return 0 ;;
57+
*) return 1 ;;
58+
esac
59+
}
60+
require_date() {
61+
local name="$1" value="$2"
62+
if ! valid_date "$value"; then
63+
echo "::error::check-package-policy: $name='$value' is not YYYY-MM-DD."
64+
echo "Refusing to run: an unparseable cutoff would silently disarm this gate."
65+
exit 1
66+
fi
67+
}
68+
require_date ENFORCE_PACKAGE_POLICY_FROM "$ENFORCE_PACKAGE_POLICY_FROM"
69+
require_date PKG_TODAY "$TODAY"
70+
71+
if [ ! -d "$ROOT" ]; then
72+
echo "::error::check-package-policy: '$ROOT' is not a directory."
73+
exit 1
74+
fi
75+
76+
# Vendored trees cannot satisfy the policy on the repo's behalf.
77+
PRUNE=( -name .git -o -name node_modules -o -name deps -o -name .lake -o -name vendor )
78+
79+
# NB: `find … | head -1` under `set -o pipefail` is a SIGPIPE race — head exits
80+
# after the first line, find keeps writing, takes SIGPIPE, and pipefail
81+
# propagates the non-zero status, aborting the script under `set -e`. It only
82+
# manifests on trees large enough that find is still running when head exits, so
83+
# small fixtures never catch it (it red two real estate repos intermittently
84+
# while every unit fixture passed). `|| true` neutralises the pipeline status;
85+
# the guix/nix decision is made from the captured value, not the exit code.
86+
find_first() {
87+
local out
88+
out="$(find "$ROOT" \( "${PRUNE[@]}" \) -prune -o \( "$@" \) -print 2>/dev/null | head -1 || true)"
89+
printf '%s' "$out"
90+
}
91+
92+
GUIX="$(find_first -name guix.scm -o -name manifest.scm -o -name channels.scm -o -name .guix-channel)"
93+
NIX="$(find_first -name flake.nix -o -name default.nix -o -name shell.nix)"
94+
95+
if [ -n "$GUIX" ]; then
96+
echo "✅ Guix package management detected (primary): ${GUIX#"$ROOT"/}"
97+
exit 0
98+
fi
99+
100+
if [ -n "$NIX" ]; then
101+
echo "✅ Nix package management detected (fallback): ${NIX#"$ROOT"/}"
102+
echo "::notice::Guix is the estate primary; Nix is the accepted fallback."
103+
exit 0
104+
fi
105+
106+
# Violation: neither packaging system is present.
107+
if [[ "$TODAY" < "$ENFORCE_PACKAGE_POLICY_FROM" ]]; then
108+
echo "::warning::No Guix or Nix packaging found — this becomes a BLOCKING" \
109+
"failure on $ENFORCE_PACKAGE_POLICY_FROM (today is $TODAY)."
110+
# Never claim a pass while the policy is unmet.
111+
echo "NOT YET ENFORCED: package policy unmet but inside the grace window."
112+
exit 0
113+
fi
114+
115+
echo "::error::Package policy violation: no Guix or Nix packaging found."
116+
echo
117+
echo "Estate policy is Guix primary / Nix fallback. Add one of:"
118+
echo " guix.scm | manifest.scm | channels.scm | .guix-channel (primary)"
119+
echo " flake.nix | default.nix | shell.nix (fallback)"
120+
echo
121+
echo "Files inside .git/ node_modules/ deps/ .lake/ vendor/ do not count."
122+
exit 1

0 commit comments

Comments
 (0)