chore(hooks): pre-push gate, secret scan, pr:evidence drafter

Phase A of the quality-gate rollout — local + pre-push gates that stop
slop before it leaves the laptop. Preventative controls, per the
architect playbook.

- .husky/pre-commit — adds scripts/secret-scan.sh as the first check
  before the existing Biome format/lint. Blocks the commit when a
  likely secret is found in staged additions. Existing Biome behaviour
  is preserved.
- .husky/pre-push (new) — runs `pnpm check` (lint + typecheck + test)
  before the branch leaves the laptop. Mirrors the `check` job in
  .github/workflows/ci.yml so PR-blocking issues surface locally first.
  Bypassable for genuine emergencies with `git push --no-verify`.
- scripts/secret-scan.sh (new) — regex scan over staged additions for
  the common accidents: AWS keys, Anthropic/OpenAI/GitHub/Slack/Google/
  Stripe tokens, JWTs, PEM private key blocks, hardcoded password
  literals. Validated against synthetic leaks of each type. No external
  tool dependency — pure bash + grep, runs in <100ms typical.
- scripts/pr-evidence.sh (new) — drafts a PR_BODY.md from the diff vs
  origin/main: file lists (added/modified/deleted), commit summaries,
  and the QA checklist. Flags screenshots as REQUIRED when the diff
  touches packages/web/src/{components,routes} or packages/desktop.
  Wired as `pnpm pr:evidence` so it runs from anywhere in the
  workspace.
- .gitignore — adds PR_BODY.md so drafts don't get committed by
  accident.

Pre-commit stays fast (<2s typical). Heavy checks (typecheck, full
test run) live in pre-push.
This commit is contained in:
BenSheridanEdwards
2026-05-24 18:29:13 +01:00
committed by Offending Commit
parent 6960bf4ffe
commit 86bb9d6be0
6 changed files with 202 additions and 0 deletions

3
.gitignore vendored
View File

@@ -41,3 +41,6 @@ dist-ssr
# Tauri
packages/desktop/src-tauri/target/
packages/desktop/src-tauri/gen/
# Drafts from scripts/pr-evidence.sh
PR_BODY.md

View File

@@ -1,4 +1,14 @@
#!/bin/sh
# Pre-commit gates — must be fast (<2s typical). Anything slow goes in pre-push.
#
# Order:
# 1. Secret scan (must run first; blocks commit if a secret leaks in)
# 2. Biome format/lint on staged TS/JS/JSON/CSS files
# 1. Secret scan over staged additions
./scripts/secret-scan.sh
# 2. Biome on staged files (auto-fixes, re-stages)
STAGED=$(git diff --cached --name-only --diff-filter=ACMR | grep -E "\.(ts|tsx|js|jsx|css|json)$" || true)
[ -z "$STAGED" ] && exit 0
pnpm exec biome check --write --staged

8
.husky/pre-push Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/sh
# Pre-push gate — runs the full quality check before the branch leaves the
# laptop. Mirrors the `check` job in .github/workflows/ci.yml.
#
# Bypass for genuine emergencies with: git push --no-verify
echo "→ Running pre-push checks (lint + typecheck + test)…"
pnpm check

View File

@@ -22,6 +22,7 @@
"check": "turbo run lint typecheck test",
"ci:web": "turbo run lint typecheck test build --filter=@openconcho/web",
"ci:desktop": "turbo run cargo-check --filter=@openconcho/desktop",
"pr:evidence": "./scripts/pr-evidence.sh",
"prepare": "husky"
},
"devDependencies": {

116
scripts/pr-evidence.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Draft a PR body for the current branch based on the diff vs origin/main.
#
# Writes to PR_BODY.md in the repo root (gitignored — see end of script).
# Pre-fills the structure required by .github/pull_request_template.md
# and flags whether screenshots are required based on touched paths.
#
# Usage:
# ./scripts/pr-evidence.sh # writes ./PR_BODY.md
# ./scripts/pr-evidence.sh > /tmp/body.md # write to stdout
set -euo pipefail
OUTPUT="${1:-PR_BODY.md}"
# Find the base branch (default origin/main) the current branch diverged from.
BASE_REF="${BASE_REF:-origin/main}"
git fetch origin main --quiet 2>/dev/null || true
MERGE_BASE=$(git merge-base HEAD "$BASE_REF" 2>/dev/null || echo "$BASE_REF")
CHANGED=$(git diff --name-only "$MERGE_BASE"...HEAD)
ADDED=$(git diff --name-status --diff-filter=A "$MERGE_BASE"...HEAD | awk '{print $2}')
MODIFIED=$(git diff --name-status --diff-filter=M "$MERGE_BASE"...HEAD | awk '{print $2}')
DELETED=$(git diff --name-status --diff-filter=D "$MERGE_BASE"...HEAD | awk '{print $2}')
# Heuristic: any touched path under packages/web/src/{components,routes} or
# packages/desktop counts as a UI change and requires screenshots.
UI_CHANGED=0
if echo "$CHANGED" | grep -qE '^(packages/web/src/(components|routes)|packages/desktop)/'; then
UI_CHANGED=1
fi
# Commits since base — useful for the "What" section.
COMMITS=$(git log --pretty=format:'- %s' "$MERGE_BASE"..HEAD)
# Tests touched?
TESTS_TOUCHED=$(echo "$CHANGED" | grep -E '(\.test\.|/test/|/e2e/)' || true)
BRANCH=$(git rev-parse --abbrev-ref HEAD)
draft() {
cat <<EOF
<!--
Auto-drafted by scripts/pr-evidence.sh from the diff vs ${BASE_REF}.
Fill in the prose sections; the file lists and checklist are pre-populated.
-->
## Why
<!-- The problem this solves, in 13 sentences. What pain or gap does this close? -->
## What
$(if [ -n "$COMMITS" ]; then printf 'Commits on this branch:\n%s\n' "$COMMITS"; else echo '<!-- describe the change -->'; fi)
$(if [ -n "$ADDED" ]; then printf '\n**Added:**\n'; printf '%s\n' "$ADDED" | sed 's/^/- /'; fi)
$(if [ -n "$MODIFIED" ]; then printf '\n**Modified:**\n'; printf '%s\n' "$MODIFIED" | sed 's/^/- /'; fi)
$(if [ -n "$DELETED" ]; then printf '\n**Deleted:**\n'; printf '%s\n' "$DELETED" | sed 's/^/- /'; fi)
## Screenshots
EOF
if [ $UI_CHANGED -eq 1 ]; then
cat <<EOF
**Required** — this PR touches packages/web/src/{components,routes} or packages/desktop.
Commit screenshots under \`docs/screenshots/<feature-slug>/\` and reference here:
\`\`\`markdown
![Description](https://raw.githubusercontent.com/BenSheridanEdwards/openconcho/${BRANCH}/docs/screenshots/<feature-slug>/01-<state>.png)
\`\`\`
See \`.claude/rules/workflows.md\` → "Open a PR" for capture + commit guidance.
EOF
else
cat <<EOF
<!-- No packages/web/src/{components,routes} or packages/desktop paths touched —
screenshots not strictly required. Delete this section if truly docs-only. -->
EOF
fi
cat <<EOF
## QA checklist
- [ ] \`pnpm typecheck\` clean locally
- [ ] \`pnpm lint\` clean locally
- [ ] \`pnpm test\` green locally
$(if [ -n "$TESTS_TOUCHED" ]; then echo '- [x] Tests touched on this branch:'; printf '%s\n' "$TESTS_TOUCHED" | sed 's/^/ - /'; else echo '- [ ] Tests added for new behaviour (or note why none are needed)'; fi)
- [ ] Manual verification: <!-- which Honcho instance, which workspace/peer, what you clicked, what you saw -->
$(if echo "$CHANGED" | grep -qE '^packages/desktop/'; then echo '- [ ] \`pnpm --filter @openconcho/desktop cargo-check\` passes'; fi)
- [x] Worked in a git worktree (current branch: \`${BRANCH}\`)
## Out-of-scope
<!-- What was intentionally left out and why. -->
## Notes
<!-- Caveats, follow-ups, anything reviewers should know. -->
EOF
}
if [ "$OUTPUT" = "-" ] || [ -t 1 ]; then
# When piped or first arg is "-", write to stdout.
if [ "${1:-}" = "-" ]; then
draft
exit 0
fi
fi
draft > "$OUTPUT"
echo "✓ Drafted PR body → ${OUTPUT}"
echo " Open it, fill in Why / Manual verification / Out-of-scope / Notes, then use as the PR body."

64
scripts/secret-scan.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Secret scan for staged files.
#
# Pre-commit hook calls this against staged additions. Fast (no external
# tool; just regex over the staged diff). Designed to catch the common
# accidents — API keys committed alongside code — not to replace a full
# secret-scanning service.
#
# Exits non-zero with a clear message if a likely secret is found.
set -euo pipefail
# Only scan added/modified content (the `+` lines in the staged diff).
# This avoids false positives from existing committed strings.
STAGED_DIFF=$(git diff --cached --diff-filter=ACMR --unified=0 -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.json' '*.yml' '*.yaml' '*.toml' '*.env*' '*.sh' '*.md' 2>/dev/null || true)
if [ -z "$STAGED_DIFF" ]; then
exit 0
fi
# Only look at added lines (starting with `+`, excluding diff headers `+++`).
ADDED=$(printf '%s\n' "$STAGED_DIFF" | grep -E '^\+[^+]' || true)
if [ -z "$ADDED" ]; then
exit 0
fi
FOUND=0
FINDINGS=""
check_pattern() {
local name="$1"
local pattern="$2"
# Use `-e` to safely pass patterns that begin with `-` (e.g. PEM headers).
if printf '%s\n' "$ADDED" | grep -qE -e "$pattern"; then
FOUND=1
FINDINGS="${FINDINGS} - ${name}\n"
fi
}
check_pattern "AWS access key" 'AKIA[0-9A-Z]{16}'
check_pattern "AWS secret key (high-entropy)" 'aws_secret_access_key[[:space:]]*[:=][[:space:]]*[A-Za-z0-9/+=]{40}'
check_pattern "Anthropic API key" 'sk-ant-[a-zA-Z0-9_-]{32,}'
check_pattern "OpenAI API key" 'sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}'
check_pattern "OpenAI project key (newer)" 'sk-proj-[a-zA-Z0-9_-]{40,}'
check_pattern "GitHub personal access token" 'gh[psoru]_[A-Za-z0-9_]{36,}'
check_pattern "GitHub fine-grained PAT" 'github_pat_[A-Za-z0-9_]{82,}'
check_pattern "Slack token" 'xox[abprs]-[A-Za-z0-9-]{10,}'
check_pattern "Google API key" 'AIza[0-9A-Za-z_-]{35}'
check_pattern "Stripe live key" 'sk_live_[A-Za-z0-9]{24,}'
check_pattern "Honcho-style JWT (likely)" 'eyJ[A-Za-z0-9_-]{20,}\.eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}'
check_pattern "RSA/EC/DSA/OpenSSH private key block" '-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'
check_pattern "Generic hardcoded password" '(password|passwd|pwd)[[:space:]]*[:=][[:space:]]*["'\'']\w{8,}["'\'']'
if [ $FOUND -eq 1 ]; then
printf '\n\033[31m✗ Secret scan: potential secrets in staged changes\033[0m\n' >&2
printf '%b' "$FINDINGS" >&2
printf '\n' >&2
printf 'If this is a false positive, bypass with: \033[33mgit commit --no-verify\033[0m\n' >&2
printf 'Otherwise: remove the secret, rotate the credential, and re-stage.\n\n' >&2
exit 1
fi
exit 0