From ff4740b7edd81a7fdfce4c14781b962e77ed9374 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Fri, 25 Sep 2026 06:28:04 -0400 Subject: [PATCH] chore: Gatehouse review, triage and pre-commit gates (constitution XII) Adds the canonical crunchtools/gatehouse workflow (guard, review and triage at v0.9.0) and the Gatehouse pre-commit hook, per constitution XII and RT #1507. Changes: gatehouse.yml added; pre-commit: +gourmand,gatehouse; gourmand CI added. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RbGtMTXvUr5u3yfQ2xaP2i --- .github/workflows/gatehouse.yml | 109 ++++++++++++++++++++++++++++++++ .github/workflows/gourmand.yml | 14 ++++ .pre-commit-config.yaml | 17 +++++ 3 files changed, 140 insertions(+) create mode 100644 .github/workflows/gatehouse.yml create mode 100644 .github/workflows/gourmand.yml create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/gatehouse.yml b/.github/workflows/gatehouse.yml new file mode 100644 index 0000000..4a045bc --- /dev/null +++ b/.github/workflows/gatehouse.yml @@ -0,0 +1,109 @@ +# Gatehouse — fork-safe code review + workflow protection, in ONE file. +# +# Drop this in your repo at .github/workflows/gatehouse.yml, add a OPENROUTER_API_KEY +# secret, and you get: +# • AI review of every PR (including from forks) — posted as a PR review +# • rejection of untrusted PRs that try to modify your workflow files +# +# Nothing is ever checked out from the PR. The fork's change is only ever read as +# a diff (data); the reviewer logic lives in the pinned gatehouse container; the +# rules (your styleguide/constitution) are fetched from YOUR base branch, not the +# PR. The one thing you don't trust — the proposed change — is only ever read. +# +# Three jobs with DIFFERENT authority: +# • guard — a real blocking gate. Mark it a REQUIRED status check so a PR +# that tampers with your workflow files is rejected. +# • review — ADVISORY. It posts findings as PR comments and always passes; +# do NOT mark it required. An LLM reviewer is non-deterministic, +# so it must never hold merge authority. (Opt into blocking with +# `blocking: true` below only if you accept that tradeoff — and +# even then, keep it out of branch protection.) +# • triage — deterministic. Fails while any finding has no reply +# (`fixed in ` or `not a bug: `). Safe to mark +# REQUIRED: it never judges code, only whether a human answered. + +name: Gatehouse + +on: + pull_request_target: + types: [opened, synchronize, reopened] + # Re-runs triage when someone replies. A review posted with GITHUB_TOKEN + # cannot trigger a workflow, so the first triage runs after `review` below. + pull_request_review_comment: + types: [created, edited, deleted] + +permissions: {} # default-deny; each job grants only what it needs + +jobs: + # ── 1) Protect the workflow files themselves ──────────────────────────────── + # Rejects PRs from forks that touch .github/workflows/. Safe under + # pull_request_target because it only reads the changed-file LIST via the + # API — it never checks out or runs your code. And because pull_request_target + # runs the BASE version of this file, a PR can't edit this job to disable it. + guard: + name: Protect workflows + # Runs on replies too. A job skipped on the reply event reports + # "skipped", which a required check counts as passing: skipping the + # guard there would let any comment on a rejected fork PR clear it. + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Reject workflow changes from untrusted PRs + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + PR: ${{ github.event.pull_request.number }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + set -euo pipefail + # Gate on where the branch lives, not on the author's role: a branch + # pushed into this repo already required write access, and a fork is + # what pull_request_target actually guards against. + if [ "$HEAD_REPO" = "$GITHUB_REPOSITORY" ]; then + echo "Head branch is in $GITHUB_REPOSITORY; allowing."; exit 0 + fi + # Both sides of a rename: `gh pr diff --name-only` lists only the new + # path, so a fork could move a workflow out of .github/workflows/ unseen. + changed="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" \ + --jq '.[] | .filename, (.previous_filename // empty)')" + if echo "$changed" | grep -qE '^\.github/workflows/'; then + echo "::error::PR modifies .github/workflows/ — not accepted from external contributors." + # Explain once, on the PR event; replies re-run the check, not the comment. + if [ "$EVENT" = "pull_request_target" ]; then + gh pr comment "$PR" --repo "$GITHUB_REPOSITORY" --body \ + $'🚫 **Workflow changes are not accepted via pull request.** Please open an issue and a maintainer will make the change.' + fi + exit 1 + fi + echo "No workflow files changed." + + # ── 2) Review the PR diff (no checkout, BYO key) ──────────────────────────── + # The reviewer is maintained centrally in gatehouse's reusable workflow and the + # pinned container — so security fixes reach you without copying logic around. + # Scope OPENROUTER_API_KEY to this reusable workflow so only it can read the key. + review: + name: Gatehouse review + if: github.event_name == 'pull_request_target' + permissions: + contents: read + pull-requests: write + uses: crunchtools/gatehouse/.github/workflows/review.yml@v0.9.0 + # Advisory by default — findings post as comments, the check always passes. + # Uncomment to let critical/high findings fail the check (still not required): + # with: + # blocking: true + secrets: + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + + # ── 3) Every finding answered ────────────────────────────────────────────── + # Runs after the review on a push, and alone on a reply (review is skipped + # then, hence always()). Read-only, no secrets. + triage: + name: Gatehouse triage + needs: review + if: always() + permissions: + pull-requests: read + uses: crunchtools/gatehouse/.github/workflows/triage.yml@v0.9.0 diff --git a/.github/workflows/gourmand.yml b/.github/workflows/gourmand.yml new file mode 100644 index 0000000..dbefc96 --- /dev/null +++ b/.github/workflows/gourmand.yml @@ -0,0 +1,14 @@ +name: Gourmand + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + gourmand: + name: Code Quality (Gourmand) + uses: crunchtools/gatehouse/.github/workflows/gourmand.yml@v0.9.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..970c16d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: local + hooks: + - id: gourmand + name: Gourmand (AI slop detection) + entry: podman --events-backend=none run --rm --log-driver=none -v .:/src:Z -w /src quay.io/crunchtools/gourmand:latest check --full + language: system + pass_filenames: false + always_run: true + stages: [pre-commit, pre-merge-commit] + - id: gatehouse + name: Gatehouse (AI code review, staged diff) + entry: bash -c 'git diff --cached | podman --events-backend=none run --rm --log-driver=none -i --env-file "$HOME/.config/mcp-env/gatehouse.env" -v .:/src:ro,Z -w /src quay.io/crunchtools/gatehouse:latest --stdin' + language: system + pass_filenames: false + always_run: true + stages: [pre-commit, pre-merge-commit]