From e5301c9857afae952ed70845910d4826cd3ae8a9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:30:30 +0100 Subject: [PATCH 1/2] fix(gates): a missing profile resolver warns, never reddens Callers pinned to a pre-2026-10-01 governance-reusable copy check-package-policy.sh alone, so the resolver is absent by construction. Measured over the 325 local callers in that lone-file shape: 305 pass, 20 Nix-only fail, same as the full-layout run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016L7GFo3yGQ2vK9YgKL2wsP --- scripts/check-package-policy.sh | 11 +++++++---- scripts/tests/governance-gates-505-test.sh | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/scripts/check-package-policy.sh b/scripts/check-package-policy.sh index f82330cb6..952ac1b97 100755 --- a/scripts/check-package-policy.sh +++ b/scripts/check-package-policy.sh @@ -136,8 +136,8 @@ RSR_PROFILE_CHECKER="${RSR_PROFILE_CHECKER:-$SCRIPT_DIR/check-rsr-profile.sh}" # Print the repo's effective capabilities, one per line; nothing if it has no # profile. A profile the reference checker cannot resolve declares nothing it # can read, so it counts as undeclared but is NAMED in a warning (e.g. an -# explicit `capabilities = []`, which check-rsr-profile.sh rejects). Only a -# missing resolver, a deployment defect, returns 1. +# explicit `capabilities = []`, which check-rsr-profile.sh rejects). A missing +# resolver is handled the same way, also with a named warning. effective_capabilities() { local f found="" out for f in "$ROOT"/.machine_readable/rsr-profile.a2ml "$ROOT"/machine-readable/rsr-profile.a2ml; do @@ -145,8 +145,11 @@ effective_capabilities() { done [ -n "$found" ] || return 0 if [ ! -f "$RSR_PROFILE_CHECKER" ]; then - echo "::error::check-package-policy: capability resolver missing at $RSR_PROFILE_CHECKER" >&2 - return 1 + # Callers pinned to a governance-reusable from before 2026-10-01 copy this + # script alone, so the resolver is absent there by construction. Reddening + # them would turn a deployment-shape gap into a policy failure. + echo "::warning::check-package-policy: capability resolver missing at $RSR_PROFILE_CHECKER (caller pin predates it); ${found#"$ROOT"/} not read, treating it as declaring no packaging capability." >&2 + return 0 fi out="$(bash "$RSR_PROFILE_CHECKER" "$ROOT" 2>&1 || true)" if ! printf '%s\n' "$out" | grep -q '^effective capabilities:'; then diff --git a/scripts/tests/governance-gates-505-test.sh b/scripts/tests/governance-gates-505-test.sh index 4763a6ed2..2dc42aebe 100755 --- a/scripts/tests/governance-gates-505-test.sh +++ b/scripts/tests/governance-gates-505-test.sh @@ -205,8 +205,8 @@ assert "unresolvable profile is NAMED in a warning, read as undeclared" 0 "could assert "unresolvable profile + real guix.scm passes before the profile is read" 0 "Guix package management detected" \ env PKG_TODAY="$AFTER" "$PKG" "$r" rm "$r/guix.scm" -# A missing resolver is a deployment defect and must refuse. -assert "missing capability resolver refuses" 1 "capability resolver missing" \ +# Old-shape callers ship this script without its resolver: warn, never redden. +assert "missing capability resolver warns, reads as undeclared" 0 "capability resolver missing" \ env PKG_TODAY="$AFTER" RSR_PROFILE_CHECKER=/nonexistent "$PKG" "$r" assert "malformed cutoff refuses to run" 1 "is not YYYY-MM-DD" \ From 78f775829c49ed77d7bac37e1158de7fa68ac782 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:37:52 +0100 Subject: [PATCH 2/2] =?UTF-8?q?fix(governance):=20pin=20Hypatia=20baseline?= =?UTF-8?q?,=20warn=E2=89=A1medium,=20self-fresh=20lock=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - governance-reusable: baseline job pins hypatia via a `hypatia-ref` input (default 51ab6496, hypatia#895) instead of floating on HEAD. - apply-baseline.sh: treat `warn` and `medium` as one severity tier, so acknowledgements written before hypatia#895 still match (standards' own baseline had 20 such entries; all 20 now suppress). - Lock gate stages standards at job.workflow_sha, retiring the hardcoded third pin that went stale twice (#946, #1119). The freshness guard and both contract tests accept that expression and still refuse github.workflow_sha / github.sha (planted negatives). - check-package-policy: a missing capability resolver (old caller pin) warns and reads as undeclared instead of failing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016L7GFo3yGQ2vK9YgKL2wsP --- .github/workflows/governance-reusable.yml | 48 +++++++++++-------- scripts/apply-baseline.sh | 7 ++- scripts/check-lock-gate-pin-freshness.sh | 18 ++++++- scripts/tests/apply-baseline-test.sh | 25 ++++++++++ .../check-lock-gate-pin-freshness-test.sh | 13 +++++ .../governance-reusable-contract-test.sh | 7 ++- tests/test_governance_reusable_shape.sh | 2 +- 7 files changed, 94 insertions(+), 26 deletions(-) diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index 8e894544d..6150c2b2a 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -7,6 +7,14 @@ name: Governance Reusable Workflow on: workflow_call: inputs: + hypatia-ref: + description: >- + Hypatia commit the baseline job builds, as a 40-hex SHA, or "HEAD" + for a canary run. Kept in step with hypatia-scan-reusable.yml's + default; a floating HEAD here broke every baseline caller when + hypatia#895 changed the emitted severity of `warn` findings. + type: string + default: 51ab6496bf47e30a0576d503df31fec30f3cfe56 runs-on: description: Runner label for all governance jobs type: string @@ -222,20 +230,25 @@ jobs: elixir-version: '1.19.4' otp-version: '28.3' - - name: Resolve Hypatia HEAD commit + - name: Resolve Hypatia scanner commit if: needs.workflow-staleness.outputs.has_baseline == 'true' id: hypatia-rev + env: + HYPATIA_REF: ${{ inputs.hypatia-ref }} run: | - # Pin the cache to the *current* Hypatia main tip. Resolved before the - # cache step because cache restore happens before the clone, so the key - # cannot hash a not-yet-cloned tree — it must hash the remote ref. - sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1) + # Resolved before the cache step because cache restore happens before + # the clone, so the key must hash the commit, not a cloned tree. + if [ "$HYPATIA_REF" = "HEAD" ]; then + sha=$(git ls-remote https://github.com/hyperpolymath/hypatia.git HEAD | cut -f1) + else + sha="$HYPATIA_REF" + fi if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then - echo "ERROR: could not resolve hypatia HEAD via git ls-remote" >&2 + echo "ERROR: hypatia-ref must be a 40-hex commit SHA or HEAD (got '$HYPATIA_REF')" >&2 exit 1 fi echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "Resolved hypatia HEAD: $sha" + echo "Resolved hypatia scanner commit: $sha" - name: Cache Hex/Mix and Scanner Build if: needs.workflow-staleness.outputs.has_baseline == 'true' @@ -1399,19 +1412,14 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - # ⚠ ENFORCED, no longer a request: scripts/check-lock-gate-pin-freshness.sh - # fails Self Test unless this commit already contains everything on - # main across the sparse-checkout list below. Change one of those - # files and the NEXT pull request must bump this line — it cannot be - # this PR's own merge commit, which does not exist yet, so the pin is - # one change behind by construction and that is the intended shape. - # Pinned to an immutable commit for the same reason as the dupkey - # helpers in workflow-lint: following `main` would let an edit in - # standards change the verdict of every already-pinned caller with no - # review in their repositories. This pin is invisible to the caller's - # `uses:` ref and to actions.lock — it is a third, independent pin, - # which is precisely how it went stale across standards#946. - ref: 5f82b635c5da5c3df44d5a0caa8983d9b95e0db9 + # The reusable's OWN commit — the one the caller's `uses:` ref + # resolved to — so the lock tooling is exactly what the caller + # pinned, never older and never following `main`. This replaced a + # hardcoded third pin that was one change behind by construction and + # went stale twice (standards#946, then #1119); + # scripts/check-lock-gate-pin-freshness.sh accepts only this + # expression or a fresh 40-hex SHA. + ref: ${{ job.workflow_sha }} path: .standards-lock persist-credentials: false sparse-checkout: | diff --git a/scripts/apply-baseline.sh b/scripts/apply-baseline.sh index 45ec103d5..b0f7f9f62 100755 --- a/scripts/apply-baseline.sh +++ b/scripts/apply-baseline.sh @@ -206,11 +206,16 @@ ANNOTATED="$(jq -n \ # referencing `.file_pattern` there would error with "Cannot index # string". Capture the entry pattern first, then reference $pat # inside the test() regex argument. + # Hypatia emitted `warn` for advisory findings until hypatia#895 + # (2026-10-01), which now emits them as `medium`. Baselines written before + # then acknowledge `warn`; treating the two as one tier keeps those + # acknowledgements matching instead of re-reporting every one as new. + def sev_tier: if . == "warn" then "medium" else . end; def match_entry(f): f as $finding | $baseline | map(select( - .severity == $finding.severity + (.severity | sev_tier) == ($finding.severity | sev_tier) # `rule_module` is a string OR a list of strings. # # ⚠ THE LIST FORM EXISTS BECAUSE ONE DEFECT CAN BE EMITTED BY TWO diff --git a/scripts/check-lock-gate-pin-freshness.sh b/scripts/check-lock-gate-pin-freshness.sh index 1d31ce75e..46d7b2f8c 100755 --- a/scripts/check-lock-gate-pin-freshness.sh +++ b/scripts/check-lock-gate-pin-freshness.sh @@ -7,8 +7,9 @@ # --------------- # `governance-reusable.yml` stages the lock-gate tooling from a THIRD pin: not # the caller's `uses:` ref and not the lockfile's record of it, but a SHA -# hardcoded inside the callee for its own `actions/checkout`. A called reusable -# workflow has no context exposing its own commit, so the hardcode is forced. +# hardcoded inside the callee for its own `actions/checkout`. That hardcode was +# believed forced; `job.workflow_sha` (the reusable's own commit) removes it, and +# this guard accepts that expression as fresh by construction. # # That third pin is invisible to every other control. When standards#946 fixed # `scripts/update-actions-lock.sh`, this pin still pointed at the commit BEFORE @@ -77,6 +78,8 @@ step_block() { ' "$(workflow_path)" } +# Assert the lock-gate step stages from job.workflow_sha, or from a 40-hex +# commit that already contains COMPARE over the staged paths; exit 1 otherwise. main() { local compare="${1:-origin/main}" local wf block pin paths diverged @@ -91,6 +94,17 @@ rename it here too rather than deleting the assertion" pin="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*ref:[[:space:]]*\([^[:space:]#]*\).*/\1/p' | head -1)" [ -n "$pin" ] || fail "step '$STEP_NAME' has no 'ref:' — it would follow the default branch" + # `job.workflow_sha` is the reusable workflow's OWN commit — the one the + # caller's `uses:` ref resolved to. Staging from it means the tooling is the + # tooling of the caller's pin, never older, so there is no third pin to go + # stale and nothing to compare. (`github.workflow_sha` / `github.sha` name + # the CALLER's commit, not this file's, and stay refused below.) + local raw_ref + raw_ref="$(printf '%s\n' "$block" | sed -n 's/^[[:space:]]*ref:[[:space:]]*//p' | head -1 | sed 's/[[:space:]]*$//')" + if printf '%s' "$raw_ref" | grep -Eq '^\$\{\{[[:space:]]*job\.workflow_sha[[:space:]]*\}\}$'; then + echo "PASS: lock gate is staged from job.workflow_sha (the reusable's own commit) — fresh by construction" + return 0 + fi printf '%s' "$pin" | grep -Eq '^[0-9a-f]{40}$' || fail "step '$STEP_NAME' is pinned to '$pin', not an immutable 40-hex commit" diff --git a/scripts/tests/apply-baseline-test.sh b/scripts/tests/apply-baseline-test.sh index 1dc10e831..e94ec6fbc 100755 --- a/scripts/tests/apply-baseline-test.sh +++ b/scripts/tests/apply-baseline-test.sh @@ -56,6 +56,31 @@ EOF assert_status "file_pattern matches nested file" \ "$WORK/findings2.json" "$WORK/baseline2.json" "1,0" +# === Case 2b: severity tier — a `warn` acknowledgement still matches the +# same finding now emitted as `medium` (hypatia#895), and vice versa; a +# different tier must not match. Real shape from standards' own baseline. +cat > "$WORK/findings2b.json" <<'EOF' +[{"severity":"medium","rule_module":"research_extensions","type":"RE001","file":".github/workflows/mirror-reusable.yml"}] +EOF +cat > "$WORK/baseline2b-warn.json" <<'EOF' +[{"severity":"warn","rule_module":"research_extensions","type":"RE001","file":".github/workflows/mirror-reusable.yml"}] +EOF +cat > "$WORK/baseline2b-high.json" <<'EOF' +[{"severity":"high","rule_module":"research_extensions","type":"RE001","file":".github/workflows/mirror-reusable.yml"}] +EOF +cat > "$WORK/findings2b-warn.json" <<'EOF' +[{"severity":"warn","rule_module":"research_extensions","type":"RE001","file":".github/workflows/mirror-reusable.yml"}] +EOF +cat > "$WORK/baseline2b-medium.json" <<'EOF' +[{"severity":"medium","rule_module":"research_extensions","type":"RE001","file":".github/workflows/mirror-reusable.yml"}] +EOF +assert_status "warn acknowledgement matches a medium finding" \ + "$WORK/findings2b.json" "$WORK/baseline2b-warn.json" "1,0" +assert_status "medium acknowledgement matches a warn finding" \ + "$WORK/findings2b-warn.json" "$WORK/baseline2b-medium.json" "1,0" +assert_status "a different tier does not match" \ + "$WORK/findings2b.json" "$WORK/baseline2b-high.json" "0,1" + # === Case 3: file_pattern MUST NOT match unrelated file (regression # against the always-matches bug from `.file_pattern` inside test()) === cat > "$WORK/findings3.json" <<'EOF' diff --git a/scripts/tests/check-lock-gate-pin-freshness-test.sh b/scripts/tests/check-lock-gate-pin-freshness-test.sh index 762cf8c62..1475ea4fc 100644 --- a/scripts/tests/check-lock-gate-pin-freshness-test.sh +++ b/scripts/tests/check-lock-gate-pin-freshness-test.sh @@ -163,6 +163,19 @@ export LOCK_GATE_WORKFLOW="$WORK/.github/workflows/governance-reusable.yml" out="$(run "$NEW")"; rc=$? check "missing ref: is refused" 1 "$rc" "$out" "would follow the default branch" +# 11. The reusable's own commit is fresh by construction and needs no compare. +write_fixture '${{ job.workflow_sha }}' +out="$(run "$NEW")"; rc=$? +check "job.workflow_sha is accepted" 0 "$rc" "$out" "fresh by construction" + +# 12. The CALLER's commit is not the reusable's: both forms stay refused. +write_fixture '${{ github.workflow_sha }}' +out="$(run "$NEW")"; rc=$? +check "github.workflow_sha (caller's) is refused" 1 "$rc" "$out" "not an immutable 40-hex commit" +write_fixture '${{ github.sha }}' +out="$(run "$NEW")"; rc=$? +check "github.sha (caller's) is refused" 1 "$rc" "$out" "not an immutable 40-hex commit" + # 10. Staging nothing must not be a free pass. cat > "$WORK/.github/workflows/governance-reusable.yml" <