Merge pull request #24 from offendingcommit/chore/local-prepush-gates
chore(hooks): local pre-push gate + secret scan
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
8
.husky/pre-push
Executable 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
|
||||
@@ -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
116
scripts/pr-evidence.sh
Executable 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 1–3 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
|
||||

|
||||
\`\`\`
|
||||
|
||||
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
64
scripts/secret-scan.sh
Executable 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
|
||||
Reference in New Issue
Block a user