From eb5a477dc9a3ca0e30b6914cf6ac53be046c86a7 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 17 Jul 2026 14:35:34 +0100 Subject: [PATCH] fix(governance): make commit-time guard verdicts survive to CI Canonical Governance failed on nearly every push today (6 of the last 8 commits) because the local pre-commit hook and the CI workflow disagreed about the canonical completion guard in two ways. Frontend-only commits skipped the guard entirely: the hook gated ALL governance checks behind governance-path detection as a perf optimization, but subsystem contracts name canonical frontend files, so those commits landed locally and failed the same guard in CI. The hook now always runs the (cheap) completion guard; only the multi-minute Go test and audit battery stays path-gated. Contract-neutral bypasses did not travel: PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT authorized the commit in the local shell and vanished, so CI re-ran the guard without it and went red. A new prepare-commit-msg hook records the reason as a Contract-Neutral trailer in the commit message, and the workflow now evaluates the guard per commit (each commit's file list against its own parent), honoring the trailer exactly as the commit-time hook honored the env var. Per-commit evaluation also stops a compliant commit from being blamed for range-mates. The new hook is registered as a worktree-sensitive governance file in governance_stage_guard.py with a matching unit test assert. --- .github/workflows/canonical-governance.yml | 50 +++++++++++++++---- .husky/pre-commit | 10 +++- .husky/prepare-commit-msg | 17 +++++++ .../release_control/governance_stage_guard.py | 1 + .../governance_stage_guard_test.py | 1 + 5 files changed, 68 insertions(+), 11 deletions(-) create mode 100644 .husky/prepare-commit-msg diff --git a/.github/workflows/canonical-governance.yml b/.github/workflows/canonical-governance.yml index ae03f9425..ab47f4b8c 100644 --- a/.github/workflows/canonical-governance.yml +++ b/.github/workflows/canonical-governance.yml @@ -79,22 +79,52 @@ jobs: fi echo "range=${range}" >> "$GITHUB_OUTPUT" - - name: Run canonical completion guard against changed files + - name: Run canonical completion guard against changed commits shell: bash run: | set -euo pipefail range="${{ steps.diff.outputs.range }}" - if [ -n "${range}" ]; then - # Pass the range base so the guard compares contract texts - # base-vs-HEAD; the CI index equals HEAD, so the default - # index comparison would misreport every contract update - # in the range as insubstantial. - git diff --name-only "${range}" \ - | python3 scripts/release_control/canonical_completion_guard.py \ - --files-from-stdin --diff-base "${range%%...*}" - else + if [ -z "${range}" ]; then printf '' | python3 scripts/release_control/canonical_completion_guard.py --files-from-stdin + exit 0 fi + base="${range%%...*}" + head_sha="${range##*...}" + # Evaluate each commit the way the commit-time hook did: its own + # file list against its own parent. A range-wide evaluation + # cannot honor per-commit Contract-Neutral trailers (recorded by + # .husky/prepare-commit-msg when the author sets + # PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT), so bypassed commits that + # passed locally would fail here. + if ! commits=$(git rev-list --reverse --no-merges "${base}..${head_sha}" 2>/dev/null); then + echo "Range base ${base} unavailable (force push?); evaluating ${head_sha} against its parent." + commits="${head_sha}" + fi + if [ -z "${commits}" ]; then + echo "No commits to evaluate in range ${range}." + exit 0 + fi + status=0 + for commit in ${commits}; do + if ! git rev-parse --verify --quiet "${commit}^" >/dev/null; then + echo "Skipping root commit ${commit} (no parent to diff against)." + continue + fi + reason=$(git log -1 --format='%(trailers:key=Contract-Neutral,valueonly,separator=; )' "${commit}" | tr '\n' ' ') + echo "::group::canonical completion guard @ ${commit}" + # Pass the parent as the diff base so the guard compares + # contract texts parent-vs-commit; the CI index equals HEAD, + # so the default index comparison would misreport contract + # updates in earlier commits as insubstantial. + if ! git diff-tree --no-commit-id --name-only -r "${commit}" \ + | PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT="${reason}" python3 scripts/release_control/canonical_completion_guard.py \ + --files-from-stdin --diff-base "${commit}^"; then + echo "Canonical completion guard failed for commit ${commit}." + status=1 + fi + echo "::endgroup::" + done + exit ${status} - name: Run status audit env: diff --git a/.husky/pre-commit b/.husky/pre-commit index dda495911..ea21d81db 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -135,7 +135,15 @@ python3 scripts/release_control/subsystem_lookup_test.py unset PULSE_READ_STAGED_GOVERNANCE elif [ -f "docs/release-control/v6/internal/status.json" ]; then -echo "Skipping governance checks (no staged governance, Go, repoctl, or hook changes)." +# The completion guard still runs on frontend-only commits: subsystem +# contracts name canonical frontend files (alerts, platform pages, ...), +# and the canonical-governance workflow re-runs this guard per commit in +# CI. Skipping it here lets a frontend commit land locally and then fail +# CI on the very same check. The guard itself is cheap (seconds); only +# the Go test + audit battery above stays gated on governance paths. +echo "Running canonical completion guard..." +python3 scripts/release_control/canonical_completion_guard.py +echo "Skipping remaining governance checks (no staged governance, Go, repoctl, or hook changes)." else echo "Governance files not present, skipping governance checks." fi diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100644 index 000000000..825d0acbf --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1,17 @@ +#!/usr/bin/env sh + +set -eu + +# Record the contract-neutral bypass in the commit itself. The canonical +# completion guard re-runs per commit on GitHub Actions, where the +# PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT env var from the local commit shell +# does not exist; this trailer is the auditable, machine-readable carrier +# the canonical-governance workflow reads back for each commit in the +# pushed range. Without it, every legitimately bypassed commit fails CI. +msg_file="$1" +reason="${PULSE_ALLOW_CONTRACT_NEUTRAL_COMMIT:-}" +reason=$(printf '%s' "$reason" | tr '\n' ' ' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') +if [ -n "$reason" ]; then + git interpret-trailers --in-place --if-exists replace \ + --trailer "Contract-Neutral: $reason" "$msg_file" +fi diff --git a/scripts/release_control/governance_stage_guard.py b/scripts/release_control/governance_stage_guard.py index c4b2cc2e8..e3a5b05b2 100644 --- a/scripts/release_control/governance_stage_guard.py +++ b/scripts/release_control/governance_stage_guard.py @@ -17,6 +17,7 @@ WORKTREE_SENSITIVE_PREFIXES = ( ) WORKTREE_SENSITIVE_EXACT_FILES = ( ".husky/pre-commit", + ".husky/prepare-commit-msg", CONTROL_PLANE_REL, ) STAGED_EXECUTION_EXACT_FILES = ( diff --git a/scripts/release_control/governance_stage_guard_test.py b/scripts/release_control/governance_stage_guard_test.py index fcf6c7c1e..90ca03621 100644 --- a/scripts/release_control/governance_stage_guard_test.py +++ b/scripts/release_control/governance_stage_guard_test.py @@ -27,6 +27,7 @@ class GovernanceStageGuardTest(unittest.TestCase): def test_is_worktree_sensitive_governance_path_matches_expected_scope(self) -> None: self.assertTrue(is_worktree_sensitive_governance_path(".husky/pre-commit")) + self.assertTrue(is_worktree_sensitive_governance_path(".husky/prepare-commit-msg")) self.assertTrue(is_worktree_sensitive_governance_path("docs/release-control/control_plane.json")) self.assertTrue(is_worktree_sensitive_governance_path("scripts/release_control/status_audit.py")) self.assertFalse(