mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
fe82cb29a3
Every commit touching user-visible frontend source rewrote the single frontend-modern/browser-verification.json, so any two open frontend pull requests conflicted as soon as either merged. A merge could not repair the loser: concluding it staged main's frontend files as if the author had changed them, and the guard then demanded a receipt for files the author never ran. The only honest recovery was a rebuild on the new main and a fresh browser pass, as #2415 -> #2418 -> #2433 showed on 2-3 October. Each commit now adds its own receipt under frontend-modern/browser-verification/, named by the fingerprint of the content it verified. Receipts are only ever added, so git merges them unaided. The guard keeps every binding it had: the commit's own parent, the exact changed paths and their content digests, a desktop and a narrow viewport, non-empty routes, states and interactions, and the range check that each changed file's final content matches a receipt from a non-merge commit in the range. Concluding a merge no longer counts content a merged parent already carries as the commit's own change. Merged content that neither parent holds is flagged and still needs a follow-up receipt in range mode, and a merge commit may not record one. The shared file is retired: no commit may write it again. A commit that modified it while its own parent still carried it stays valid evidence in range mode, so a pull request that still rewrites it is repaired by merging main and taking main's copy rather than by a rebuild. The file is deleted in a change of its own, because deleting it here would conflict with every pull request that still rewrites it. --write-template writes the skeleton at the path the staged content requires. --prune deletes receipts already on main. A change that records a receipt may not delete one: git pairs the two as a rename, which conflicts with any other change deleting the same receipt.
253 lines
12 KiB
Bash
Executable File
253 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
# Pre-commit hook to prevent committing restricted or sensitive data
|
|
RESTRICTED_FILES="active_subs.json charges.json customers.json subscriptions.json"
|
|
|
|
echo "Running sensitivity check..."
|
|
|
|
for file in $RESTRICTED_FILES; do
|
|
if git diff --cached --name-only | grep -q "^${file}$"; then
|
|
echo "BLOCKED: restricted file pattern matched: ${file}"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
# Block sensitive file types from ever being committed
|
|
SENSITIVE_EXTENSIONS="\.pem$|\.p12$|\.pfx$|\.key$|\.keystore$|\.jks$|\.enc$|id_rsa$|id_ed25519$|id_ecdsa$"
|
|
if git diff --cached --name-only | grep -qE "$SENSITIVE_EXTENSIONS"; then
|
|
echo "BLOCKED: sensitive file type staged for commit:"
|
|
git diff --cached --name-only | grep -E "$SENSITIVE_EXTENSIONS"
|
|
echo "If this is intentional (e.g. a template), use: git commit --no-verify"
|
|
exit 1
|
|
fi
|
|
|
|
# Gitleaks: comprehensive secret scanning (gracefully skips if not installed)
|
|
if command -v gitleaks >/dev/null 2>&1; then
|
|
echo "Running gitleaks secret scan..."
|
|
if ! gitleaks protect --staged --config .gitleaks.toml --no-banner 2>/dev/null; then
|
|
echo "BLOCKED: gitleaks detected secrets in staged changes."
|
|
echo "Run 'gitleaks protect --staged --verbose' for details."
|
|
exit 1
|
|
fi
|
|
echo "Gitleaks scan passed."
|
|
else
|
|
# Fallback: broader pattern check when gitleaks is not installed
|
|
# Covers Stripe, AWS, GCP, OpenAI, private keys, and generic high-entropy tokens
|
|
STAGED_DIFF=$(git diff --cached | grep -E "^\+" | grep -v ".husky/pre-commit" | grep -v "_test\.go")
|
|
|
|
if echo "$STAGED_DIFF" | grep -qE "(cus_|sub_|ch_|pi_|pm_|sk_live_|sk_test_|rk_live_|rk_test_|whsec_)"; then
|
|
echo "WARNING: Potential Stripe identifiers found in staged changes."
|
|
echo " Use 'git diff --cached' to review before proceeding."
|
|
if [ -t 0 ]; then
|
|
printf " Proceed anyway? (y/N): "
|
|
read REPLY < /dev/tty
|
|
echo
|
|
if [ "$REPLY" != "y" ] && [ "$REPLY" != "Y" ]; then
|
|
exit 1
|
|
fi
|
|
else
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
if echo "$STAGED_DIFF" | grep -qE "(AKIA[0-9A-Z]{16}|-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----|sk-[a-zA-Z0-9]{20,}|AIza[0-9A-Za-z_-]{35})"; then
|
|
echo "BLOCKED: Likely secret detected in staged changes (AWS key, private key, or API key)."
|
|
echo " Use 'git diff --cached' to review."
|
|
echo " Install gitleaks for more precise scanning: brew install gitleaks"
|
|
exit 1
|
|
fi
|
|
fi
|
|
echo "Sensitivity check passed."
|
|
|
|
STAGED_FILES="$(git diff --cached --name-only)"
|
|
|
|
staged_files_match() {
|
|
printf '%s\n' "$STAGED_FILES" | grep -qE "$1"
|
|
}
|
|
|
|
# Normalize staged source before any guard hashes or audits it. The browser
|
|
# verification receipt binds to exact frontend bytes and is named by their
|
|
# fingerprint, so formatting after its guard can produce a commit that passed
|
|
# locally but correctly fails in CI.
|
|
python3 scripts/release_control/format_staged_go.py
|
|
python3 scripts/release_control/format_staged_frontend.py
|
|
|
|
# Shipped docs under frontend-modern/public/docs are byte-for-byte copies of
|
|
# repo docs, enforced only by a CI vitest the hooks never run. Two 2026-09-01
|
|
# commits (f4886c2dfb, f313882a7b) each broke main's Frontend job by editing a
|
|
# mirrored doc without its copy; catch that before the commit exists.
|
|
if staged_files_match '^docs/|^frontend-modern/public/docs/|^(SECURITY|TERMS|ARCHITECTURE|CONTRIBUTING)\.md$'; then
|
|
echo "Running shipped docs mirror check..."
|
|
python3 scripts/check_docs_mirror.py --staged
|
|
fi
|
|
|
|
echo "Running browser verification guard..."
|
|
python3 scripts/release_control/browser_verification_guard.py
|
|
|
|
# Governance checks gating.
|
|
# Run the full governance audit + Go test suite only when:
|
|
# (a) the internal governance directory is present (skipped on fresh public-repo clones), AND
|
|
# (b) the staged changes actually touch governance-relevant paths.
|
|
# A pure frontend-only commit then skips the multi-minute Go test and Python
|
|
# helper unit tests entirely. This matters when multiple agent harnesses
|
|
# (Codex, Claude Code, Anti-Gravity) are committing in parallel against the
|
|
# same tree; previously every commit serialized behind ~15-20 minutes of
|
|
# audits regardless of what was actually staged.
|
|
#
|
|
# Governance-relevant paths: anything under docs/release-control/, the
|
|
# release_control Python scripts, the repoctl Go package the audits live in,
|
|
# any Go file (the Go test reads governance JSON via Go code), the module
|
|
# manifests, the VERSION marker, and the husky hooks themselves (so changes
|
|
# to this file still get validated).
|
|
GOVERNANCE_PATHS='^docs/release-control/|^scripts/release_control/|^internal/repoctl/|\.go$|^go\.(mod|sum|work|work\.sum)$|^VERSION$|^\.husky/'
|
|
if [ -f "docs/release-control/v6/internal/status.json" ] && staged_files_match "$GOVERNANCE_PATHS"; then
|
|
|
|
echo "Running governance stage guard..."
|
|
python3 scripts/release_control/governance_stage_guard.py
|
|
|
|
echo "Running staged commit shape guard..."
|
|
python3 scripts/release_control/staged_commit_shape_guard.py
|
|
|
|
echo "Running control plane audit..."
|
|
python3 scripts/release_control/control_plane_audit.py --check --staged
|
|
|
|
echo "Running canonical completion guard..."
|
|
python3 scripts/release_control/canonical_completion_guard.py
|
|
|
|
echo "Running status audit..."
|
|
python3 scripts/release_control/status_audit.py --check --staged
|
|
|
|
echo "Validating Pulse Intelligence release-gate schema..."
|
|
python3 scripts/release_control/pulse_intelligence_gate.py --validate-only --matrix docs/release-control/v6/internal/pulse-intelligence-release-gate.json
|
|
|
|
echo "Running registry audit..."
|
|
python3 scripts/release_control/registry_audit.py --check --staged
|
|
|
|
echo "Running contract audit..."
|
|
python3 scripts/release_control/contract_audit.py --check --staged
|
|
|
|
echo "Running governance guardrail tests..."
|
|
export PULSE_READ_STAGED_GOVERNANCE=1
|
|
# These tests are I/O bound (they os.Stat every evidence path status.json
|
|
# references), so Go's 10m default is a false-failure trap when several agents
|
|
# commit at once and starve each other. Raise the ceiling rather than skip the
|
|
# tests; override with PULSE_HOOK_GO_TEST_TIMEOUT on a quiet machine.
|
|
go test ./internal/repoctl -count=1 -timeout "${PULSE_HOOK_GO_TEST_TIMEOUT:-30m}"
|
|
|
|
echo "Running readiness assertion guard..."
|
|
python3 scripts/release_control/readiness_assertion_guard.py --staged --active-target --proof-type automated
|
|
|
|
echo "Running release-control helper unit tests..."
|
|
python3 scripts/release_control/canonical_completion_guard_test.py
|
|
python3 scripts/release_control/browser_verification_guard_test.py
|
|
python3 scripts/release_control/control_plane_audit_test.py
|
|
python3 scripts/release_control/contract_audit_test.py
|
|
python3 scripts/release_control/format_staged_go_test.py
|
|
python3 scripts/release_control/format_staged_frontend_test.py
|
|
python3 scripts/release_control/governance_stage_guard_test.py
|
|
python3 scripts/release_control/pulse_intelligence_gate_test.py
|
|
python3 scripts/release_control/release_promotion_policy_support_test.py
|
|
python3 scripts/release_control/registry_audit_test.py
|
|
python3 scripts/release_control/readiness_assertion_guard_test.py
|
|
(cd scripts/release_control && git -C ../.. show :scripts/release_control/release_promotion_policy_test.py | python3 -)
|
|
(cd scripts/release_control && git -C ../.. show :scripts/release_control/reconcile_release_convergence_test.py | python3 -)
|
|
python3 scripts/release_control/repo_file_io_test.py
|
|
python3 scripts/release_control/staged_commit_shape_guard_test.py
|
|
python3 scripts/release_control/status_audit_test.py
|
|
python3 scripts/release_control/subsystem_contracts_test.py
|
|
python3 scripts/release_control/subsystem_lookup_test.py
|
|
unset PULSE_READ_STAGED_GOVERNANCE
|
|
|
|
elif [ -f "docs/release-control/v6/internal/status.json" ]; then
|
|
# 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
|
|
|
|
# Run Go linting (if golangci-lint is available)
|
|
if command -v golangci-lint >/dev/null 2>&1; then
|
|
if staged_files_match '(^|/).*\.go$|(^|/)go\.(mod|sum|work|work\.sum)$|^\.golangci\.ya?ml$'; then
|
|
echo "Running golangci-lint..."
|
|
# Lint only packages containing staged Go files, not the entire repo.
|
|
# Packages in nested Go modules (own go.mod, e.g.
|
|
# tests/integration/mock-github-server) must lint from their module
|
|
# root or the typecheck fails with "main module does not contain
|
|
# package"; group staged dirs by nearest enclosing go.mod.
|
|
STAGED_GO_DIRS=$(git diff --cached --name-only | grep '\.go$' | xargs -I{} dirname {} 2>/dev/null | sort -u)
|
|
STAGED_GO_PKGS=""
|
|
NESTED_GO_MODULES=""
|
|
for staged_dir in $STAGED_GO_DIRS; do
|
|
# Staged deletions can leave a directory that no longer exists
|
|
# (e.g. removing a whole package); golangci-lint hard-fails on
|
|
# missing paths, so skip them. Deletion-only commits fall through
|
|
# to the ./... default below.
|
|
[ -d "$staged_dir" ] || continue
|
|
module_root="$staged_dir"
|
|
while [ "$module_root" != "." ] && [ ! -f "$module_root/go.mod" ]; do
|
|
module_root=$(dirname "$module_root")
|
|
done
|
|
if [ "$module_root" = "." ]; then
|
|
STAGED_GO_PKGS="$STAGED_GO_PKGS ./$staged_dir"
|
|
else
|
|
NESTED_GO_MODULES=$(printf '%s\n%s' "$NESTED_GO_MODULES" "$module_root")
|
|
fi
|
|
done
|
|
NESTED_GO_MODULES=$(printf '%s\n' "$NESTED_GO_MODULES" | sed '/^$/d' | sort -u)
|
|
if [ -z "$STAGED_GO_PKGS" ] && [ -z "$NESTED_GO_MODULES" ]; then
|
|
STAGED_GO_PKGS="./..."
|
|
fi
|
|
# Report only issues introduced by the staged diff. Pre-existing
|
|
# violations in touched packages do not block new commits, but any
|
|
# new violation on a changed line still fails the commit. Override
|
|
# with GOLANGCI_LINT_NEW_FROM_REV=HEAD~5 etc, or "" to lint everything.
|
|
NEW_FROM_REV="${GOLANGCI_LINT_NEW_FROM_REV-HEAD}"
|
|
NEW_FROM_FLAG=""
|
|
if [ -n "$NEW_FROM_REV" ]; then
|
|
NEW_FROM_FLAG="--new-from-rev=$NEW_FROM_REV"
|
|
fi
|
|
# golangci-lint's typecheck consumes export data written by the go
|
|
# command, and GOTOOLCHAIN=auto never downgrades: a system Go newer
|
|
# than the linter's vendored x/tools (go1.27 vs v1.64.8's go1.26)
|
|
# fails with "export data version 4 is greater than maximum
|
|
# supported version 2" and a cascade of bogus "type X has no field"
|
|
# errors. Pin lint to the toolchain go.mod declares, the same one
|
|
# release builds use. The root pin also covers nested modules; their
|
|
# go directives must not exceed it. Override with
|
|
# GOLANGCI_LINT_GOTOOLCHAIN.
|
|
LINT_GOTOOLCHAIN="${GOLANGCI_LINT_GOTOOLCHAIN:-$(awk '$1 == "toolchain" { tc = $2 } $1 == "go" && go == "" { go = "go" $2 } END { if (tc != "") print tc; else if (go != "") print go; else print "auto" }' go.mod)}"
|
|
if [ -n "$STAGED_GO_PKGS" ]; then
|
|
GOTOOLCHAIN="$LINT_GOTOOLCHAIN" GOMAXPROCS="${GOMAXPROCS:-2}" golangci-lint run --concurrency "${GOLANGCI_LINT_CONCURRENCY:-2}" --timeout "${GOLANGCI_LINT_TIMEOUT:-20m}" $NEW_FROM_FLAG $STAGED_GO_PKGS
|
|
fi
|
|
for nested_module in $NESTED_GO_MODULES; do
|
|
echo "Running golangci-lint in nested module $nested_module..."
|
|
(cd "$nested_module" && GOTOOLCHAIN="$LINT_GOTOOLCHAIN" GOMAXPROCS="${GOMAXPROCS:-2}" golangci-lint run --concurrency "${GOLANGCI_LINT_CONCURRENCY:-2}" --timeout "${GOLANGCI_LINT_TIMEOUT:-20m}" $NEW_FROM_FLAG ./...)
|
|
done
|
|
else
|
|
echo "Skipping golangci-lint (no staged Go/module/linter changes)."
|
|
fi
|
|
fi
|
|
|
|
# Run frontend linting (if package.json has lint script)
|
|
if [ -f frontend-modern/package.json ]; then
|
|
if staged_files_match "^frontend-modern/"; then
|
|
echo "Running frontend linter..."
|
|
cd frontend-modern
|
|
npm run lint
|
|
cd ..
|
|
else
|
|
echo "Skipping frontend lint (no staged frontend changes)."
|
|
fi
|
|
fi
|
|
|
|
echo "Pre-commit checks passed!"
|