From 5dbc008ea03b7a526c3e5d58e0224b8fe75c71a3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:16:26 +0100 Subject: [PATCH] fix(governance): actions-lock-verify as its own context; advisory jobs cannot fail silently (step 2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour-only repairs to governance-reusable.yml. No job name or caller id changes: every live ruleset types " / ", so a rename would turn a required context into a phantom estate-wide. tests/test_governance_reusable_shape.sh freezes the 15 names. - New job `Actions lockfile verify` (actions-lock-verify). It fetches scripts/check-actions-lock-gate.sh + update-actions-lock.sh from standards at `job.workflow_sha` (the reusable's own pin, one speed of propagation) instead of running them from the consumer's cwd, which is why "Workflow security linter" died with exit 127 ("scripts/update-actions-lock.sh: No such file") on every consumer. Lock present -> `gh actions-lock --verify-local`, rc propagated. Lock absent + unpinned uses -> fail. Lock absent + all SHA-pinned -> warning until ENFORCE_ACTIONS_LOCK_FROM (2026-10-01), error after. 11 fixture cases incl. a planted positive (stub verifier exit 1 -> gate exit 1). - workflow-lint no longer carries the lock check (one context, one cause). - Every job honours inputs.runs-on (two were hardcoded). - Live Actions policy: "not evaluated" is a ::warning, not a ::notice. - Security policy: weak-crypto and http findings say ADVISORY in the message; hardcoded secrets say they FAIL the job. - gates.json: "Live Actions policy (credentialed advisory)" and "Code quality + docs" join never_required_contexts; match rule and context-freeze note recorded. - Spec §14: fold table for the estate policy files (step 2f). Known: actionlint reports `job.workflow_sha` as undefined; it is undocumented but populated with the called reusable's SHA (verified on consumer runs pinned at 571cc734). scripts/tests/wave3-scorecards-test.sh fails 2/9 on unmodified main too (pre-existing, not touched here). --- .github/workflows/governance-reusable.yml | 126 ++++++++++-------- config/rulesets/gates.json | 10 +- .../2026-09-02-cicd-regularisation-design.md | 22 +++ scripts/check-actions-lock-gate.sh | 74 ++++++++++ scripts/tests/check-actions-lock-gate-test.sh | 70 ++++++++++ tests/test_governance_reusable_shape.sh | 87 ++++++++++++ 6 files changed, 329 insertions(+), 60 deletions(-) create mode 100755 scripts/check-actions-lock-gate.sh create mode 100755 scripts/tests/check-actions-lock-gate-test.sh create mode 100755 tests/test_governance_reusable_shape.sh diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index db637c453..4ff768fc6 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -23,7 +23,7 @@ permissions: jobs: workflow-staleness: name: Check Workflow Staleness - runs-on: ubuntu-latest + runs-on: ${{ inputs.runs-on }} timeout-minutes: 10 outputs: has_baseline: ${{ steps.check.outputs.has_baseline }} @@ -135,7 +135,7 @@ jobs: GH_TOKEN: ${{ secrets.HYPATIA_SCAN_PAT }} if: ${{ env.GH_TOKEN == '' }} run: | - echo "::notice::Live Actions policy was not evaluated: HYPATIA_SCAN_PAT was not supplied by the caller. The separate tree allowlist gate still ran." + echo "::warning::Live Actions policy was not evaluated: HYPATIA_SCAN_PAT was not supplied by the caller. The separate tree allowlist gate still ran." # shellcheck disable=SC2016 printf '%s\n' \ '### Live Actions policy not evaluated' \ @@ -160,7 +160,7 @@ jobs: rc=$? set -e if [ "$rc" -eq 3 ]; then - echo "::notice::Live Actions policy was not evaluated: the supplied credential could not read the Administration endpoint." + echo "::warning::Live Actions policy was not evaluated: the supplied credential could not read the Administration endpoint." printf '%s\n' \ '### Live Actions policy not evaluated' \ '' \ @@ -179,7 +179,7 @@ jobs: # repo without a baseline blocked forever, by construction. The job now # always runs; the EXPENSIVE steps are guarded individually, so it still # costs nothing when there is no baseline to validate. - runs-on: ubuntu-latest + runs-on: ${{ inputs.runs-on }} timeout-minutes: 15 steps: - name: Checkout caller repository @@ -753,17 +753,17 @@ jobs: FAILED=false WEAK_CRYPTO=$(grep -rE 'md5\(|sha1\(' --include="*.py" --include="*.rb" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" . 2>/dev/null | grep -v 'checksum\|cache\|test\|spec' | head -5 || true) if [ -n "$WEAK_CRYPTO" ]; then - echo "⚠️ Weak crypto (MD5/SHA1) detected. Use SHA256+ for security:" + echo "::warning::Weak crypto (MD5/SHA1) detected — ADVISORY, does not fail this job. Use SHA256+:" echo "$WEAK_CRYPTO" fi HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5 || true) if [ -n "$HTTP_URLS" ]; then - echo "⚠️ HTTP URLs found. Use HTTPS:" + echo "::warning::HTTP URLs found — ADVISORY, does not fail this job. Use HTTPS:" echo "$HTTP_URLS" fi SECRETS=$(grep -rEi '(api_key|apikey|secret_key|password)\s*[=:]\s*["\x27][A-Za-z0-9+/=]{20,}' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.env" . 2>/dev/null | grep -v 'example\|sample\|test\|mock\|placeholder' | head -3 || true) if [ -n "$SECRETS" ]; then - echo "❌ Potential hardcoded secrets detected!" + echo "::error::Potential hardcoded secrets detected — this FAILS the job:" FAILED=true fi if [ "$FAILED" = true ]; then @@ -1123,7 +1123,6 @@ jobs: path: .standards-dupkey sparse-checkout: | scripts/check-workflow-duplicate-keys.sh - scripts/update-actions-lock.sh tools/policy/check-workflows-parse.sh sparse-checkout-cone-mode: false # ⚠ Not fatal if the file is absent. This checkout is pinned to @@ -1177,18 +1176,6 @@ 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. - 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" \ - "standards@main nor present locally." - exit 1 - fi - cp "$LOCK_SCRIPT" "$RUNNER_TEMP/update-actions-lock.sh" rm -rf .standards-dupkey bash "$RUNNER_TEMP/dupkeys.sh" .github/workflows @@ -1220,43 +1207,6 @@ jobs: done [ $failed -eq 1 ] && { echo "Add SPDX header + permissions:"; exit 1; } echo "All workflows have SPDX headers + permissions" - - name: Check locked or SHA-pinned actions - env: - GH_TOKEN: ${{ github.token }} - run: | - if [ -f .github/workflows/actions.lock ]; then - # actions.lock is the authoritative immutable resolution for both - # direct actions and their transitive dependencies. Do not also - # rewrite direct refs to raw SHAs: gh actions-lock omits refs that - # no tag or branch contains, and GitHub then rejects the workflow - # at startup. Measured in oikosbot PR #78 on 2026-08-29: five - # previously executable workflows became startup_failure after the - # redundant direct-SHA conversion; restoring their locked version - # refs made GitHub's native resolver accept them again. - gh extension install github/gh-actions-lock - bash "$RUNNER_TEMP/update-actions-lock.sh" --verify-local - echo "Immutable direct and transitive lockfile coverage verified" - else - unpinned=$(grep -rnE --include='*.yml' --include='*.yaml' \ - "^[[:space:]]+uses:" .github/workflows/ | \ - grep -v "@[a-f0-9]\{40\}" | \ - grep -v "uses: \./\|uses: docker://\|uses: actions/github-script\|uses: hyperpolymath/standards/" || true) - if [ -n "$unpinned" ]; then - echo "ERROR: no .github/workflows/actions.lock in THIS TREE, and these refs are not SHA-pinned." - echo " Prefer \`gh actions-lock\` — it also locks the transitive dependencies" - echo " of composite actions, which an inline SHA cannot express." - echo " Do NOT do both: gh actions-lock refuses a ref no tag or branch contains," - echo " so inline pinning REMOVES actions from the lockfile." - echo "$unpinned" - exit 1 - fi - echo "All actions are SHA-pinned" - fi - # The step above proves a pin has the right SHAPE. It cannot prove the - # SHA EXISTS — a fabricated 40-hex string passes it. Measured 2026-07-28: - # 112 of 613 unique estate pins (18%) do not resolve, in 876 committed - # files. An unresolvable `uses:` yields NO check run, so those repos look - # green while the job never ran. This step closes that gap. - name: Checkout standards for the pin-existence gate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -1284,6 +1234,68 @@ jobs: fi echo "No critical duplicates found" + # actions-lock-verify — its own context so a lockfile red names its cause + # (spec 2026-09-02-cicd-regularisation-design §6.4, R2). Formerly a step + # buried inside workflow-lint, where "Workflow security linter" red could + # mean SPDX, permissions, duplicate keys, parse, pin-resolve OR the lock. + # The gate logic lives in scripts/check-actions-lock-gate.sh (tested by + # scripts/tests/check-actions-lock-gate-test.sh): lock present → the + # authoritative `gh actions-lock --verify-local`; lock absent → unpinned + # refs are red now, all-pinned-but-no-lock is a ::warning until + # ENFORCE_ACTIONS_LOCK_FROM (2026-10-01), red after. + # + # Standards is checked out at `job.workflow_sha` = the SHA of THIS reusable + # as pinned by the caller (verified 2026-09-02 on consumer run logs), so the + # gate script and the YAML move together — one speed, no `ref: main` float. + actions-lock-verify: + name: Actions lockfile verify + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout caller repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.repository }} + ref: ${{ github.sha }} + - name: Checkout standards for the lock gate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/standards + ref: ${{ job.workflow_sha }} + path: .standards-lock + persist-credentials: false + sparse-checkout: | + scripts/check-actions-lock-gate.sh + scripts/update-actions-lock.sh + sparse-checkout-cone-mode: false + - name: Install gh actions-lock + env: + GH_TOKEN: ${{ github.token }} + run: gh extension install github/gh-actions-lock + - name: Verify actions.lock (or SHA pins during the grace window) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + if [ "$GITHUB_REPOSITORY" = hyperpolymath/standards ]; then + SRC=scripts + echo "Using this repository's own gate + verifier (standards self-lint)." + else + SRC=.standards-lock/scripts + fi + for f in check-actions-lock-gate.sh update-actions-lock.sh; do + if [ ! -f "$SRC/$f" ]; then + echo "::error::actions-lock gate: $f not found in $SRC (standards checkout at job.workflow_sha failed?)" + exit 1 + fi + cp "$SRC/$f" "$RUNNER_TEMP/$f" + done + rm -rf .standards-lock + ACTIONS_LOCK_VERIFIER="$RUNNER_TEMP/update-actions-lock.sh" \ + bash "$RUNNER_TEMP/check-actions-lock-gate.sh" .github/workflows + trusted-base: name: Trusted-base reduction policy runs-on: ${{ inputs.runs-on }} diff --git a/config/rulesets/gates.json b/config/rulesets/gates.json index 029c4465d..830fe0e54 100644 --- a/config/rulesets/gates.json +++ b/config/rulesets/gates.json @@ -6,7 +6,9 @@ "context_shape": "job name; for a reusable caller it is ' / '", "integration_id": 15368, "if_no_run_yet": "omit that file's contexts and report it; never write a context nothing has emitted", - "if_zero_contexts_overall": "do not write the required_status_checks rule at all; report the repo as UNGATED" + "if_zero_contexts_overall": "do not write the required_status_checks rule at all; report the repo as UNGATED", + "never_required_match": "an entry in never_required_contexts matches either the whole context or the part after the first \" / \" (the reusable job name); it is removed from the derived set, never typed", + "context_freeze": "reusable job `name:` values and caller job ids are FROZEN until this derivation replaces every typed ruleset: renaming either turns a live required context into a phantom on every consumer. Adding a job is allowed. Guard: standards tests/test_governance_reusable_shape.sh" }, "profiles": { "base": { @@ -93,7 +95,9 @@ ], "never_required_contexts": [ "Allowlist Preflight", - "SonarCloud Code Analysis", - "CodeQL (default setup)" + "Code quality + docs", + "CodeQL (default setup)", + "Live Actions policy (credentialed advisory)", + "SonarCloud Code Analysis" ] } diff --git a/docs/superpowers/specs/2026-09-02-cicd-regularisation-design.md b/docs/superpowers/specs/2026-09-02-cicd-regularisation-design.md index ee9d9fcbd..ecef0f240 100644 --- a/docs/superpowers/specs/2026-09-02-cicd-regularisation-design.md +++ b/docs/superpowers/specs/2026-09-02-cicd-regularisation-design.md @@ -450,3 +450,25 @@ Facts found while writing the canonical artefacts; each overrides the section it | 7.6 ADR template | `docs/decisions/ADR-.adoc` | ADR files are `ADR--.adoc`; the live template 404s on every repo. Canon = code-search URL `search?q=ADR-+path%3Adocs%2Fdecisions&type=code` | `ADR-003.adoc` on standards = HTTP 404, 2026-09-02 | | 7.2 allowlist enforcement | "prune" implied enforcement | `verified_allowed` is true, so verified creators bypass the list; prune is hygiene, R1 is enforced by deleting workflows. **O12 added**: flip `verified_allowed` to false after a `uses:` census | `config/settings/actions-allowlist.json` | | 7.3 direct push | unstated | With every bypass at `pull_request` and no `always` actor, main is PR-only for everyone including the owner; emergency path = disable the ruleset. FYI posted on #715 | `config/rulesets/base.json` | + +## 14. Fold table — estate policy files → governance-reusable jobs (step 2f) + +Doc only; step 5 deletes the left column once the right column is green on the pilots. +Job names are the frozen contexts (§6.2; guard = `tests/test_governance_reusable_shape.sh`). + +| Estate file (repos) | Governance job (context) | Fold decision | +|---|---|---| +| `security-policy.yml` (61) | `Security policy checks` | Fold. Weak crypto / HTTP URL findings are ADVISORY `::warning`; hardcoded secrets FAIL. Delete file | +| `runtime-policy.yml` (47), `language-policy.yml` (12), `ts-blocker.yml` (7) | `Language / package anti-pattern policy` | Fold. Delete files | +| `npm-bun-blocker.yml` (7) | — | DROP: contradicts the Bun ruling; no job carries it | +| `rsr-antipattern.yml` (24) | `Language / package anti-pattern policy` | DROP the file (its reusable does not exist); the job already covers the anti-pattern list | +| `wellknown-enforcement.yml` (60) | `Well-Known (RFC 9116 + RSR)` | Fold. Open: is `security.txt` MUST or SHOULD → owner ruling **O13** (#715). Until ruled the job keeps today's severity | +| `estate-rules.yml` (40) | `Trusted-base reduction policy` + `Licence consistency` | Fold; the two jobs are the surviving halves. Delete file | +| `guix-policy.yml` (25), `guix-nix-policy.yml` (35) | `Guix packaging policy (Nix retired)` | Fold. Presence-only checks in the files were fake; the job's checks must keep a planted positive | +| `container-policy.yml` (7) | `Security policy checks` | Fold the digest-pin rule into the job as a new sub-check with a planted positive; delete file | +| `workflow-linter.yml` (141) | `Workflow security linter` + `Actions lockfile verify` | Fold. Inline `uses:` are linted too (the file missed them). Lock verification is its own context (PR 2a) | +| `dogfood-gate.yml` (240, 164 variants): invisible-character job | new governance job (name TBD in PR 2b, e.g. `Invisible characters`) | REMAKE as a GATE. Reference implementation = double-track-browser #90: `grep -aP '(*UTF)[…]'`, in-step probe that the pattern fires on a planted NBSP, `::error` + exit 1 on findings | +| `dogfood-gate.yml`: groove / A2ML / K9 checks | — | Keep as the `*-ecosystem/validate-action` opt-ins (§5.6); not governance | +| `cicd-suite/spdx-license-check`, `palimpsest-license` action | `Licence consistency` | Fold. SPDX line-1 rule lives in one place | +| `Validate Hypatia Baseline` (governance) vs `hypatia-scan` threshold | `Validate Hypatia Baseline` + `scan / Hypatia Neurosymbolic Analysis` | Align: governance validates baseline file shape only (info); the severity threshold (high) is owned by `hypatia-scan-reusable`. One owner per rule, no double jeopardy | +| `Live Actions policy (credentialed advisory)`, `Code quality + docs`, `Allowlist Preflight` | (advisory) | Never required contexts (`config/rulesets/gates.json`). They warn, they do not gate | diff --git a/scripts/check-actions-lock-gate.sh b/scripts/check-actions-lock-gate.sh new file mode 100755 index 000000000..dcea7c581 --- /dev/null +++ b/scripts/check-actions-lock-gate.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-actions-lock-gate.sh — the `actions-lock-verify` GATE (R2: SHA pins + +# actions.lock everywhere; spec 2026-09-02-cicd-regularisation-design §6.4). +# +# Three outcomes, never a silent pass: +# lockfile present → run the authoritative verifier (`gh actions-lock +# --verify-local` via scripts/update-actions-lock.sh) and +# propagate its exit status. A corrupted lock goes RED. +# lockfile absent, → RED: an unpinned `uses:` is a violation today, lock or +# unpinned refs no lock. +# lockfile absent, → grace window: `::warning` + "NOT YET ENFORCED" and exit +# all SHA-pinned 0 until ENFORCE_ACTIONS_LOCK_FROM; `::error` + exit 1 +# from that date. The sweep (spec §10 step 5) lands the +# lockfiles before the date; the date makes the gate +# real without red-flooding 300 repos on day one. +# +# Test seams (used by scripts/tests/check-actions-lock-gate-test.sh): +# LOCK_TODAY override today's date (YYYY-MM-DD) +# ENFORCE_ACTIONS_LOCK_FROM override the cutoff (default 2026-10-01) +# ACTIONS_LOCK_VERIFIER path to update-actions-lock.sh (default: sibling) +# +# Usage: check-actions-lock-gate.sh [WORKFLOWS_DIR] (default .github/workflows) +set -uo pipefail + +WF_DIR="${1:-.github/workflows}" +TODAY="${LOCK_TODAY:-$(date -u +%F)}" +ENFORCE_FROM="${ENFORCE_ACTIONS_LOCK_FROM:-2026-10-01}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFIER="${ACTIONS_LOCK_VERIFIER:-$SCRIPT_DIR/update-actions-lock.sh}" + +if [ ! -d "$WF_DIR" ]; then + echo "::error::actions-lock gate: workflows directory not found: $WF_DIR" + exit 2 +fi + +if [ -f "$WF_DIR/actions.lock" ]; then + if [ ! -f "$VERIFIER" ]; then + echo "::error::actions-lock gate: lockfile present but verifier not found at $VERIFIER" + exit 2 + fi + echo "Lockfile present: running the authoritative verifier ($VERIFIER --verify-local)." + bash "$VERIFIER" --verify-local + rc=$? + if [ "$rc" -ne 0 ]; then + echo "::error::actions-lock gate: lockfile verification FAILED (exit $rc). Regenerate with scripts/update-actions-lock.sh in the same PR as the uses: change." + exit "$rc" + fi + echo "Immutable direct and transitive lockfile coverage verified." + exit 0 +fi + +# No lockfile. Unpinned refs are a violation regardless of the grace window. +unpinned=$(grep -rnE --include='*.yml' --include='*.yaml' "^[[:space:]]+-?[[:space:]]*uses:" "$WF_DIR" \ + | grep -vE "@[a-f0-9]{40}([[:space:]]|$)" \ + | grep -vE "uses:[[:space:]]+(\./|docker://|actions/github-script|hyperpolymath/standards/)" || true) +if [ -n "$unpinned" ]; then + echo "::error::actions-lock gate: no $WF_DIR/actions.lock AND these refs are not SHA-pinned:" + echo "$unpinned" + echo " Prefer \`gh actions-lock\` (scripts/update-actions-lock.sh): it also locks the" + echo " transitive dependencies of composite actions, which an inline SHA cannot express." + exit 1 +fi + +if [[ "$TODAY" < "$ENFORCE_FROM" ]]; then + echo "::warning::actions-lock gate: no $WF_DIR/actions.lock. All refs are SHA-pinned, but the lockfile becomes REQUIRED on $ENFORCE_FROM (today is $TODAY). Run scripts/update-actions-lock.sh." + echo "NOT YET ENFORCED: lockfile missing but inside the grace window." + exit 0 +fi + +echo "::error::actions-lock gate: no $WF_DIR/actions.lock and the grace window closed on $ENFORCE_FROM (today is $TODAY). Run scripts/update-actions-lock.sh and commit the lockfile." +exit 1 diff --git a/scripts/tests/check-actions-lock-gate-test.sh b/scripts/tests/check-actions-lock-gate-test.sh new file mode 100755 index 000000000..8e8584d45 --- /dev/null +++ b/scripts/tests/check-actions-lock-gate-test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# check-actions-lock-gate-test.sh — fixture suite for scripts/check-actions-lock-gate.sh, +# the `actions-lock-verify` GATE. Every branch is driven on both sides of the +# grace cutoff. The "lockfile present" branches use a stub verifier so the +# planted positive (a verifier that says the lock is bad → gate RED) runs +# without `gh actions-lock` installed. +# +# Run: bash scripts/tests/check-actions-lock-gate-test.sh +set -uo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GATE="$SCRIPT_DIR/../check-actions-lock-gate.sh" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +pass=0; fail=0 + +assert() { + local label="$1" want="$2" needle="$3"; shift 3 + local out status + out="$("$@" 2>&1)"; status=$? + if [ "$status" != "$want" ]; then + echo "FAIL: $label — expected exit $want, got $status" + echo " output: $(printf '%s' "$out" | head -3 | tr '\n' '|')" + fail=$((fail + 1)); return + fi + if [ "$needle" != "-" ] && ! printf '%s' "$out" | grep -qF "$needle"; then + echo "FAIL: $label — exit $status correct, but output lacked '$needle'" + echo " output: $(printf '%s' "$out" | head -3 | tr '\n' '|')" + fail=$((fail + 1)); return + fi + echo "PASS: $label"; pass=$((pass + 1)) +} + +# mkwf [lock] +mkwf() { + local d="$WORK/$1/.github/workflows"; mkdir -p "$d" + if [ "$2" = pinned ]; then + printf 'jobs:\n a:\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7\n - uses: ./local\n - uses: hyperpolymath/standards/.github/workflows/x.yml@main\n' > "$d/ci.yml" + else + printf 'jobs:\n a:\n steps:\n - uses: actions/checkout@v4\n' > "$d/ci.yml" + fi + [ "${3:-}" = lock ] && : > "$d/actions.lock" + printf '%s' "$d" +} +OK_VERIFIER="$WORK/verifier-ok.sh"; printf '#!/usr/bin/env bash\necho stub-verifier-ok; exit 0\n' > "$OK_VERIFIER" +BAD_VERIFIER="$WORK/verifier-bad.sh"; printf '#!/usr/bin/env bash\necho stub-verifier-BAD; exit 1\n' > "$BAD_VERIFIER" +BEFORE="2026-09-15"; AFTER="2026-10-01" + +echo "=== lockfile present ===" +d=$(mkwf a pinned lock) +assert "lock + verifier OK → 0" 0 "coverage verified" env ACTIONS_LOCK_VERIFIER="$OK_VERIFIER" bash "$GATE" "$d" +assert "lock + verifier BAD → 1 (planted positive)" 1 "verification FAILED" env ACTIONS_LOCK_VERIFIER="$BAD_VERIFIER" bash "$GATE" "$d" +assert "lock + verifier missing → 2" 2 "verifier not found" env ACTIONS_LOCK_VERIFIER="$WORK/nope.sh" bash "$GATE" "$d" +assert "lock + unpinned refs: verifier decides, not the grep" 0 "stub-verifier-ok" env ACTIONS_LOCK_VERIFIER="$OK_VERIFIER" bash "$GATE" "$(mkwf b unpinned lock)" + +echo "=== no lockfile ===" +d=$(mkwf c pinned) +assert "no lock, pinned, before cutoff → 0 NOT YET ENFORCED" 0 "NOT YET ENFORCED" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" +assert "no lock, pinned, before cutoff emits ::warning" 0 "::warning::" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" +assert "no lock, pinned, on cutoff → 1" 1 "grace window closed" env LOCK_TODAY="$AFTER" bash "$GATE" "$d" +assert "no lock, pinned, custom cutoff honoured → 1" 1 "::error::" env LOCK_TODAY="2026-09-03" ENFORCE_ACTIONS_LOCK_FROM="2026-09-02" bash "$GATE" "$d" +d=$(mkwf e unpinned) +assert "no lock, unpinned, before cutoff → 1 (no grace for unpinned)" 1 "not SHA-pinned" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" +assert "no lock, unpinned, after cutoff → 1" 1 "not SHA-pinned" env LOCK_TODAY="$AFTER" bash "$GATE" "$d" +assert "missing workflows dir → 2" 2 "not found" bash "$GATE" "$WORK/does-not-exist/.github/workflows" + +echo; echo "passed=$pass failed=$fail" +[ "$fail" -eq 0 ] diff --git a/tests/test_governance_reusable_shape.sh b/tests/test_governance_reusable_shape.sh new file mode 100755 index 000000000..d311d9c29 --- /dev/null +++ b/tests/test_governance_reusable_shape.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +# +# test_governance_reusable_shape.sh — structural guards on +# .github/workflows/governance-reusable.yml (spec 2026-09-02-cicd-regularisation +# §6.2, §6.4; PR 2a). These are the properties a red MUST be able to name: +# +# 1. CONTEXT FREEZE: the set of reusable job `name:` values is exactly the +# frozen list. Every live ruleset types " / "; a +# rename turns that context into a phantom on ~350 repos. Adding a job is +# allowed (it adds a context); renaming or removing one is not, until the +# applier (step 3) derives contexts from emitted check-runs. +# 2. Every job honours `inputs.runs-on` (no hardcoded runner). +# 3. `actions-lock-verify` is its own job, runs the tested gate script, and +# checks standards out at job.workflow_sha (the reusable's own SHA), never +# at a floating `main`. +# 4. The lock check no longer hides inside workflow-lint. +# 5. `continue-on-error: true` appears only in the jobs listed as advisory or +# on a fetch step whose absence a later step turns into ::error. +# 6. Every advisory job name is excluded from ruleset derivation via +# config/rulesets/gates.json never_required_contexts. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +F="$ROOT/.github/workflows/governance-reusable.yml" +G="$ROOT/config/rulesets/gates.json" +pass=0; fail=0 +ok() { echo "PASS: $1"; pass=$((pass+1)); } +bad() { echo "FAIL: $1"; fail=$((fail+1)); } + +# job block extractor: lines of job (from " :" to the next " :") +job_block() { awk -v id="$1" '$0==" "id":" {p=1; print; next} p && /^ [a-z][a-z-]*:$/ {exit} p {print}' "$F"; } + +# 1. context freeze +FROZEN="Check Workflow Staleness +Allowlist Preflight +Live Actions policy (credentialed advisory) +Validate Hypatia Baseline +Language / package anti-pattern policy +Guix packaging policy (Nix retired) +Security policy checks +Code quality + docs +Well-Known (RFC 9116 + RSR) +Workflow security linter +Actions lockfile verify +Trusted-base reduction policy +Licence consistency +Exemption ratchet +Debt ratchet" +ACTUAL=$(grep -P '^ name: ' "$F" | sed 's/^ name: //') +if [ "$(printf '%s' "$FROZEN" | sort)" = "$(printf '%s' "$ACTUAL" | sort)" ]; then ok "job names match the frozen context list ($(printf '%s\n' "$ACTUAL" | wc -l) jobs)" +else bad "job names drifted from the frozen list — renames create phantom contexts estate-wide"; diff <(printf '%s\n' "$FROZEN" | sort) <(printf '%s\n' "$ACTUAL" | sort); fi + +# 2. runs-on +if grep -q 'runs-on: ubuntu-latest' "$F"; then bad "hardcoded runs-on present (inputs.runs-on ignored)"; else ok "every job uses inputs.runs-on"; fi +njobs=$(grep -cP '^ [a-z][a-z-]*:$' "$F"); nro=$(grep -c 'runs-on: ${{ inputs.runs-on }}' "$F") +[ "$njobs" -eq "$nro" ] && ok "runs-on count ($nro) equals job count ($njobs)" || bad "runs-on count $nro != job count $njobs" + +# 3. actions-lock-verify job +B=$(job_block actions-lock-verify) +[ -n "$B" ] && ok "actions-lock-verify job exists" || bad "actions-lock-verify job missing" +printf '%s' "$B" | grep -q 'check-actions-lock-gate.sh' && ok "actions-lock-verify runs the tested gate script" || bad "actions-lock-verify does not run check-actions-lock-gate.sh" +printf '%s' "$B" | grep -q 'ref: ${{ job.workflow_sha }}' && ok "actions-lock-verify pins standards at job.workflow_sha" || bad "actions-lock-verify standards checkout not pinned to job.workflow_sha" +printf '%s' "$B" | grep -q 'ref: main' && bad "actions-lock-verify floats a standards checkout at main" || ok "actions-lock-verify has no floating ref: main" +printf '%s' "$B" | grep -q 'ACTIONS_LOCK_VERIFIER=' && ok "gate is pointed at the fetched verifier" || bad "ACTIONS_LOCK_VERIFIER not set for the gate" + +# 4. workflow-lint no longer carries the lock step +W=$(job_block workflow-lint) +printf '%s' "$W" | grep -v '^ *#' | grep -q -- '--verify-local' && bad "workflow-lint still runs the lock verifier (should be its own context)" || ok "lock verification moved out of workflow-lint" +printf '%s' "$W" | grep -q 'LOCK_SCRIPT' && bad "workflow-lint still copies the lock verifier" || ok "workflow-lint no longer fetches update-actions-lock.sh" + +# 5. continue-on-error allow-list +ALLOWED="language-policy quality workflow-lint" +viol=0 +for id in $(grep -oP '^ \K[a-z][a-z-]*(?=:$)' "$F"); do + case " $ALLOWED " in *" $id "*) continue;; esac + if job_block "$id" | grep -q 'continue-on-error: true'; then echo " continue-on-error in gate job: $id"; viol=1; fi +done +[ "$viol" -eq 0 ] && ok "continue-on-error confined to {$ALLOWED}" || bad "continue-on-error leaked into a gate job" + +# 6. advisory jobs excluded from derivation +for n in "Live Actions policy (credentialed advisory)" "Code quality + docs" "Allowlist Preflight"; do + jq -e --arg n "$n" '.never_required_contexts | index($n)' "$G" >/dev/null && ok "gates.json never_required_contexts has '$n'" || bad "gates.json never_required_contexts lacks '$n'" +done + +echo; echo "passed=$pass failed=$fail" +[ "$fail" -eq 0 ]