diff --git a/.github/scripts/authorize-claude-workflow.sh b/.github/scripts/authorize-claude-workflow.sh deleted file mode 100644 index 5498182fd..000000000 --- a/.github/scripts/authorize-claude-workflow.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# Decide whether the trigger (PR author, comment author, labeler) is -# authorized to spawn a Claude workflow on this repo. Driven by the -# `authorize` job in claude-review.yml / claude-mention.yml / -# claude-issue-to-pr.yml. -# -# Trust set: every `@HarperFast/` handle in this repo's -# `.github/CODEOWNERS`. Same set as the people we trust to review code, -# aligned by construction. Falls back to `@HarperFast/developers` if -# CODEOWNERS is missing, empty, unparseable, or contains no HarperFast -# handles. External-org handles in CODEOWNERS are deliberately ignored -# — only HarperFast members are admitted. -# -# Inputs: -# USERS_TO_CHECK — newline-separated logins; ALL must pass. -# Empty / whitespace-only entries are skipped. -# ADMIT_CLAUDE_BOT — "true" admits `claude[bot]` without a team -# check (used by claude-review for AI-authored -# PRs from the issue-to-PR pipeline). Anything -# else requires team membership for every user. -# DEFAULT_TOKEN — token for the CODEOWNERS read (typically -# $GITHUB_TOKEN; needs `contents: read`). -# ORG_TOKEN — token for `orgs/.../teams/.../memberships/...` -# (App-installation token with `Members: Read`, -# scoped to this `authorize` job only). -# GITHUB_REPOSITORY — owner/repo (auto-set by GitHub Actions). -# GITHUB_OUTPUT — output file path. -# -# Outputs (to $GITHUB_OUTPUT): -# authorized=true|false -set -uo pipefail - -# Resolve the trust set from CODEOWNERS. The default token reads the -# workflow repo's own .github/CODEOWNERS via the contents API. -# Anything missing / empty / unparseable / containing no HarperFast -# handles falls back to the default team. -CODEOWNERS=$(GH_TOKEN="$DEFAULT_TOKEN" gh api \ - "repos/${GITHUB_REPOSITORY}/contents/.github/CODEOWNERS" \ - --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || true) -TEAMS=$(printf '%s' "$CODEOWNERS" | grep -oE '@HarperFast/[a-zA-Z0-9_-]+' | sort -u | sed 's|@HarperFast/||' || true) - -if [ -z "$TEAMS" ]; then - echo "::notice::No @HarperFast/ handles found in .github/CODEOWNERS (missing, empty, or only external orgs). Defaulting to developers." - TEAMS="developers" -fi - -# Fail closed if USERS_TO_CHECK is empty or whitespace-only. The -# main loop below skips empty entries with `[ -z "$user" ] && continue` -# and would otherwise fall through to `authorized=true` if there was -# nothing to check. An authorize job that forgot to set USERS_TO_CHECK -# (or a malicious change that removed it) must NOT silently admit -# every event — refuse here. -if [ -z "${USERS_TO_CHECK//[[:space:]]/}" ]; then - echo "::error::USERS_TO_CHECK is empty or whitespace-only — denying by default. The authorize job must explicitly pass at least one login (PR author, commenter, labeler, etc.)." - echo "authorized=false" >> "$GITHUB_OUTPUT" - exit 0 -fi - -echo "Trust set (HarperFast teams from CODEOWNERS):" -for t in $TEAMS; do echo " - @HarperFast/$t"; done - -# is_authorized -# Admits claude[bot] iff ADMIT_CLAUDE_BOT=true; otherwise tries each -# team in the trust set in order. Returns 0 on the first hit. -is_authorized() { - local user="$1" - - if [ "${ADMIT_CLAUDE_BOT:-false}" = "true" ] && [ "$user" = "claude[bot]" ]; then - echo " → admitted: claude[bot]" - return 0 - fi - - for team in $TEAMS; do - # /orgs/{org}/teams/{team_slug}/memberships/{username} - # returns 200 for active members, 404 otherwise. - if GH_TOKEN="$ORG_TOKEN" gh api "orgs/HarperFast/teams/${team}/memberships/${user}" --silent >/dev/null 2>&1; then - echo " → admitted via @HarperFast/${team} membership" - return 0 - fi - done - - echo " → not a member of any HarperFast team in the trust set" - return 1 -} - -while IFS= read -r raw_user; do - user="$(printf '%s' "$raw_user" | awk '{$1=$1;print}')" - [ -z "$user" ] && continue - echo "Checking: $user" - if ! is_authorized "$user"; then - echo "User '$user' not authorized. Skipping the gated job." - echo "authorized=false" >> "$GITHUB_OUTPUT" - exit 0 - fi -done <<< "${USERS_TO_CHECK:-}" - -echo "authorized=true" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/compose-review-scope.sh b/.github/scripts/compose-review-scope.sh deleted file mode 100644 index 87c02cee2..000000000 --- a/.github/scripts/compose-review-scope.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# Compose the layered review scope from individual layer files into a -# single markdown blob, and emit it as the `composed` output via -# $GITHUB_OUTPUT. Driven by claude-review.yml's "Compose review scope -# from layers" step. -# -# Inputs: -# LAYERS — newline-separated layer names (e.g. "universal\nharper/v5") -# GITHUB_OUTPUT — path to the GitHub Actions output file -# -# Layer files live at .ai-review-prompts/.md (the path the -# `Clone review prompts` step checks out into). Missing layers emit -# a workflow warning and continue; an empty composed result fails -# the step (no review scope = no review discipline). -set -euo pipefail - -OUT=/tmp/composed-scope.md -: > "$OUT" -while IFS= read -r raw_layer; do - # Trim whitespace around each layer name. - layer="$(printf '%s' "$raw_layer" | awk '{$1=$1;print}')" - [ -z "$layer" ] && continue - file=".ai-review-prompts/${layer}.md" - if [ ! -f "$file" ]; then - echo "::warning::Review layer '$layer' not found at $file; skipping." - continue - fi - { - cat "$file" - printf '\n\n' - } >> "$OUT" -done <<< "${LAYERS:-}" - -BYTES=$(wc -c < "$OUT") -echo "Composed ${BYTES} bytes from review layers" -if [ "$BYTES" -eq 0 ]; then - echo "::error::Composed review scope is empty — all layers missing or unreadable." - exit 1 -fi - -# Random heredoc delimiter — collision-proof against any content a -# future layer file might include. $GITHUB_OUTPUT uses heredoc -# syntax; a fixed marker could be forged (or coincidentally appear) -# in layer content and corrupt the output. -DELIM="EOF_$(openssl rand -hex 16)" -{ - echo "composed<<${DELIM}" - cat "$OUT" - echo "${DELIM}" -} >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/find-prior-review-comment.sh b/.github/scripts/find-prior-review-comment.sh deleted file mode 100644 index 1ace4230e..000000000 --- a/.github/scripts/find-prior-review-comment.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Find the prior `claude-review:v1`-marker'd top-level review comment -# on a PR (if any) and write its integer database ID to -# $GITHUB_OUTPUT under key `id`. Empty when no prior exists. -# -# Why marker-based lookup: `--edit-last` filters by authenticated -# identity (`claude[bot]`) only — so after a `@claude` mention, the -# most recent claude[bot] comment is the mention response, and -# `--edit-last` clobbers it. Every review comment starts with -# ``; mention responses never carry the -# marker, so this lookup targets only the review comment. -# -# Inputs: -# GH_TOKEN — token with `pull-requests: read` -# GITHUB_REPOSITORY — owner/repo (auto-set by GitHub Actions) -# PR_NUMBER — pull request number -# GITHUB_OUTPUT — output file path -set -uo pipefail - -EXISTING_ID=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --jq '[.[] | select(.user.login == "claude[bot]") | select(.body | startswith(""))] | last | .id // empty') - -if [ -n "$EXISTING_ID" ]; then - echo "Prior review comment: $EXISTING_ID" -else - echo "No prior review comment found — agent will post fresh." -fi -echo "id=${EXISTING_ID}" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/log-review-to-ai-review-log.sh b/.github/scripts/log-review-to-ai-review-log.sh deleted file mode 100644 index 2aac0db3a..000000000 --- a/.github/scripts/log-review-to-ai-review-log.sh +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env bash -# Log this run's PR review to the central HarperFast/ai-review-log -# tracker — finds the per-PR issue by stable title prefix and -# appends a comment, or creates a new issue if none exists. Driven -# by claude-review.yml's "Log review to ai-review-log" step. -# -# Best-effort: never fails the job. A missing `AI_REVIEW_LOG_TOKEN` -# secret, an absent claude review comment, or a stale comment all -# exit cleanly with a notice/warning rather than failing. -# -# Inputs: -# GH_TOKEN — token with `pull-requests: read` -# AI_REVIEW_LOG_TOKEN — fine-grained PAT scoped to ai-review-log -# with `issues: write` (optional — missing -# skips logging with a warning) -# PR_NUMBER — pull request number -# PR_URL — html URL of the PR -# REVIEW_STATUS — outcome of the Claude review step -# (success / failure / cancelled / etc.) -# REPO_SHORT — short repo name (e.g. "harper") -# GITHUB_REPOSITORY — owner/repo of the PR's repo -# GITHUB_RUN_ID — current Actions run ID (for staleness -# guard) -# RUNNER_TEMP — runner temp dir (where the agent's -# optional run-notes file lives) -set -uo pipefail - -if [ -z "${AI_REVIEW_LOG_TOKEN:-}" ]; then - echo "::warning::AI_REVIEW_LOG_TOKEN secret not set; skipping log entry." - exit 0 -fi - -# When this workflow job started. Used to filter out stale Claude -# review comments from previous runs so a cancelled in-flight run -# (e.g. from a force-push) doesn't re-log a prior run's content as -# a fresh finding. -JOB_STARTED=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq '.run_started_at // empty') - -# Fetch the marker'd review comment via raw API. We can't use -# `gh pr view --json comments` because (a) it doesn't expose -# `updated_at` (which we need below for the staleness guard now -# that comments are edited in place), and (b) we need the marker -# filter to ignore `@claude` mention responses that share the -# `claude[bot]` identity. -CLAUDE_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \ - --jq '[.[] | select(.user.login == "claude[bot]") | select(.body | startswith(""))] | last // empty') - -if [ -z "$CLAUDE_JSON" ] || [ "$CLAUDE_JSON" = "null" ]; then - echo "No marker'd Claude review comment found on PR #$PR_NUMBER (review_status=$REVIEW_STATUS); skipping log." - exit 0 -fi - -CLAUDE_BODY=$(printf '%s' "$CLAUDE_JSON" | jq -r '.body // empty') -# Prefer updated_at (reflects the most recent edit) over created_at -# (frozen at original post time) — comments are now edited in place -# across runs. -CLAUDE_AT=$(printf '%s' "$CLAUDE_JSON" | jq -r '.updated_at // .created_at // empty') - -if [ -z "$CLAUDE_BODY" ]; then - echo "Claude review comment had empty body; skipping log." - exit 0 -fi - -# ISO-8601 lexicographic compare — both are UTC timestamps in the -# same shape, so string comparison is sound. -if [ -n "$JOB_STARTED" ] && [ -n "$CLAUDE_AT" ] && [ "$CLAUDE_AT" \< "$JOB_STARTED" ]; then - echo "::notice::Latest Claude review comment update ($CLAUDE_AT) predates this job's start ($JOB_STARTED); skipping to avoid re-logging stale content." - exit 0 -fi - -# Title: count findings (lines starting with `### `). The -# "no blockers" branch matches the sentinel phrase anywhere in the -# body — the concise prompt's `Reviewed; no blockers found.` doesn't -# start with "no blockers", so an anchored regex would miss it. -# Anywhere-match is safe because the phrase is a deliberate output -# from the prompt. -if printf '%s' "$CLAUDE_BODY" | grep -qi 'no blockers found'; then - COUNT_PART="no blockers" -else - FINDING_COUNT=$(printf '%s\n' "$CLAUDE_BODY" | grep -c '^### [0-9]' || true) - COUNT_PART="${FINDING_COUNT} finding(s) — triage pending" -fi - -if [ "$REVIEW_STATUS" = "success" ]; then - TITLE="[$REPO_SHORT] PR #$PR_NUMBER: $COUNT_PART" -else - TITLE="[$REPO_SHORT] PR #$PR_NUMBER: $COUNT_PART (review $REVIEW_STATUS — may be incomplete)" -fi - -BODY=$(printf '**Source:** %s\n**Repo:** %s\n**PR:** #%s\n**Model:** claude-sonnet-4-6\n**Phase:** baseline\n**Review job status:** %s\n**Date:** %s\n\n---\n\n%s\n' \ - "$PR_URL" "$REPO_SHORT" "$PR_NUMBER" "$REVIEW_STATUS" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$CLAUDE_BODY") - -# Structured run notes from the agent (optional). This is the -# channel that keeps verbose context off the PR — the agent writes -# to a fixed path under $RUNNER_TEMP, and we append here so the log -# issue gets the full picture while the PR comment stays concise. -# Absent file is fine; means the run had nothing structured to -# capture. -NOTES_FILE="${RUNNER_TEMP:-/tmp}/claude-review-notes.md" -if [ -f "$NOTES_FILE" ]; then - NOTES_CONTENT=$(cat "$NOTES_FILE") - BODY=$(printf '%s\n\n---\n\n%s\n' "$BODY" "$NOTES_CONTENT") - echo "Appended $(wc -c < "$NOTES_FILE") bytes of run notes from $NOTES_FILE" -else - echo "No run notes file at $NOTES_FILE — skipping notes append" -fi - -# One ai-review-log issue per PR. Stable prefix `[] PR #:` -# lets us look up an existing issue for this PR across runs even -# though the count/status portion past the colon changes per run. -# List API (not search) is used because search is eventually- -# consistent — a same-day second review run might fire before the -# first issue is indexed. -TITLE_PREFIX="[$REPO_SHORT] PR #$PR_NUMBER:" - -EXISTING_NUMBER=$(curl -sS \ - -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/HarperFast/ai-review-log/issues?labels=repo:$REPO_SHORT&state=all&per_page=100&sort=created&direction=desc" \ - | jq -r --arg prefix "$TITLE_PREFIX" \ - '[.[] | select(.title | startswith($prefix))] | first | .number // empty') - -if [ -n "$EXISTING_NUMBER" ] && [ "$EXISTING_NUMBER" != "null" ]; then - # Existing issue: append a comment, refresh the title to reflect - # this run's status. Title refresh is best-effort — we still - # report success on the comment alone. - COMMENT_PAYLOAD=$(jq -nc --arg body "$BODY" '{body: $body}') - HTTP_C=$(curl -sS -o /tmp/ai-log-comment-resp.json -w '%{http_code}' -X POST \ - -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/HarperFast/ai-review-log/issues/$EXISTING_NUMBER/comments" \ - -d "$COMMENT_PAYLOAD") - - PATCH_PAYLOAD=$(jq -nc --arg title "$TITLE" '{title: $title}') - HTTP_T=$(curl -sS -o /tmp/ai-log-patch-resp.json -w '%{http_code}' -X PATCH \ - -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "https://api.github.com/repos/HarperFast/ai-review-log/issues/$EXISTING_NUMBER" \ - -d "$PATCH_PAYLOAD") - - if [ "$HTTP_C" -ge 200 ] && [ "$HTTP_C" -lt 300 ]; then - COMMENT_URL=$(jq -r '.html_url' /tmp/ai-log-comment-resp.json) - echo "Logged review as comment on existing issue: $COMMENT_URL" - else - echo "::warning::ai-review-log comment POST failed (HTTP $HTTP_C):" - cat /tmp/ai-log-comment-resp.json - fi - - if [ "$HTTP_T" -lt 200 ] || [ "$HTTP_T" -ge 300 ]; then - echo "::warning::ai-review-log title PATCH failed (HTTP $HTTP_T):" - cat /tmp/ai-log-patch-resp.json - fi -else - # No existing issue for this PR — create one. - CREATE_PAYLOAD=$(jq -nc \ - --arg title "$TITLE" \ - --arg repo_label "repo:$REPO_SHORT" \ - --arg body "$BODY" \ - '{title: $title, body: $body, labels: [$repo_label, "verdict:pending", "phase:baseline"]}') - - HTTP=$(curl -sS -o /tmp/ai-log-resp.json -w '%{http_code}' -X POST \ - -H "Authorization: Bearer $AI_REVIEW_LOG_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/HarperFast/ai-review-log/issues \ - -d "$CREATE_PAYLOAD") - - if [ "$HTTP" -ge 200 ] && [ "$HTTP" -lt 300 ]; then - ISSUE_URL=$(jq -r '.html_url' /tmp/ai-log-resp.json) - echo "Logged review to new issue: $ISSUE_URL" - else - echo "::warning::ai-review-log POST failed (HTTP $HTTP):" - cat /tmp/ai-log-resp.json - fi -fi diff --git a/.github/scripts/parse-claude-mention.sh b/.github/scripts/parse-claude-mention.sh deleted file mode 100644 index 30149fa65..000000000 --- a/.github/scripts/parse-claude-mention.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# Decide whether to proceed with an `@claude` mention and which model -# to use, based on the comment body. Driven by claude-mention.yml's -# "Parse mention" step. -# -# Rules (the precision gate; the job-level `if:` is a cheap -# pre-filter that only checks substring containment): -# 1. `@claude` must be the FIRST non-whitespace token (word- -# boundary after) — rules out `@claudette`, inline prose -# mentions ("saw @claude's fix"), and quoted replies -# (`> @claude ...`) where the reply is addressing a human. -# 2. Case-insensitive word-boundary `deep` anywhere in the body -# escalates to Opus. Sonnet is the default. -# -# Inputs: -# BODY — comment body (verbatim) -# GITHUB_OUTPUT — output file path -# -# Outputs (to $GITHUB_OUTPUT): -# proceed=true|false -# model=claude-opus-4-7|claude-sonnet-4-6 (only when proceed=true) -set -uo pipefail - -if ! printf '%s' "$BODY" | grep -Pqz '\A\s*@claude\b'; then - echo "Comment does not start with @claude; skipping." - echo "proceed=false" >> "$GITHUB_OUTPUT" - exit 0 -fi - -if printf '%s' "$BODY" | grep -Piq '\bdeep\b'; then - echo "model=claude-opus-4-7" >> "$GITHUB_OUTPUT" - echo "Selected claude-opus-4-7 (deep requested)" -else - echo "model=claude-sonnet-4-6" >> "$GITHUB_OUTPUT" - echo "Selected claude-sonnet-4-6 (default)" -fi -echo "proceed=true" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/validate-auth-gate-invariants.sh b/.github/scripts/validate-auth-gate-invariants.sh deleted file mode 100644 index b57e1a898..000000000 --- a/.github/scripts/validate-auth-gate-invariants.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env bash -# Validate that the AI workflow auth gate structure is preserved -# across all `claude-*.yml` workflows. STRUCTURAL lint, not a semantic -# test — catches the obvious attacks (delete the authorize job, drop -# the `needs:` dependency, broaden permissions, change the -# if-expression to a tautology). Subtle attacks (e.g., modifying the -# bash logic inside the auth check to admit everyone) are out of -# scope for this validator and are caught by CODEOWNERS review on -# `.github/` changes. -# -# Defense in depth: branch-protection on `main` should make this -# workflow's job a REQUIRED status check. -# -# Inputs (none — runs in the workflow checkout). Validates: -# .github/workflows/claude-*.yml -# -# Exit code: -# 0 all workflows pass -# 1 any check failed (errors emitted as ::error::) -set -uo pipefail - -fail() { - echo "::error::$1" - exit 1 -} - -# yq is pre-installed on ubuntu-latest runners. -command -v yq >/dev/null || fail "yq not available on runner" - -shopt -s nullglob -files=(.github/workflows/claude-*.yml) -if [ "${#files[@]}" -eq 0 ]; then - echo "No claude-*.yml workflows found; nothing to validate." - exit 0 -fi - -for f in "${files[@]}"; do - echo "" - echo "=== Validating $f ===" - - # 1. The authorize job exists. - yq -e '.jobs.authorize' "$f" >/dev/null \ - || fail "$f: missing 'authorize' job" - - # 2. authorize.outputs.authorized is wired to some step output. - output_expr=$(yq -r '.jobs.authorize.outputs.authorized // ""' "$f") - [ -n "$output_expr" ] \ - || fail "$f: authorize job has no outputs.authorized" - echo "$output_expr" | grep -q 'steps\..*\.outputs\.authorized' \ - || fail "$f: authorize.outputs.authorized must come from a step output (got: $output_expr)" - - # 3. authorize uses actions/create-github-app-token (pinned to a SHA). - app_token_step=$(yq -r '.jobs.authorize.steps[] | select(.uses != null) | .uses' "$f" | grep '^actions/create-github-app-token@' || true) - [ -n "$app_token_step" ] \ - || fail "$f: authorize doesn't use actions/create-github-app-token" - echo "$app_token_step" | grep -qE '@[0-9a-f]{40}( |$)' \ - || fail "$f: actions/create-github-app-token must be pinned to a 40-char SHA (got: $app_token_step)" - - # 4. authorize.permissions doesn't grant any write-level scope. - write_perms=$(yq -r '.jobs.authorize.permissions | (.[] // "") | select(. == "write")' "$f" 2>/dev/null || true) - [ -z "$write_perms" ] \ - || fail "$f: authorize.permissions grants 'write' on at least one scope — auth job must be read-only" - - # 5. Required secrets are referenced (the auth check can't work without them). - grep -q 'HARPERFAST_AI_CLIENT_ID' "$f" \ - || fail "$f: HARPERFAST_AI_CLIENT_ID secret not referenced" - grep -q 'HARPERFAST_AI_APP_PRIVATE_KEY' "$f" \ - || fail "$f: HARPERFAST_AI_APP_PRIVATE_KEY secret not referenced" - - # 6. The authorize job sets USERS_TO_CHECK on at least one of its - # steps. The auth script (`authorize-claude-workflow.sh`) fails - # closed if USERS_TO_CHECK is empty, but the workflow still - # shouldn't ship without it — make the omission a structural - # error rather than a silent runtime denial. Defense in depth - # against a PR that drops the env var thinking the script will - # "do the right thing". - # - # NOTE: yq on ubuntu-latest is mikefarah/yq (Go), not jq. It does - # NOT support jq's `empty` keyword, and an earlier version of this - # check using `// empty` lexer-erred silently (`2>/dev/null` ate it) - # and produced a false fail on workflows that DID set the env var. - # `select(. != null)` is the idiomatic yq filter for "skip steps - # without this env var"; `head -1` collapses the per-step stream to - # a single value (or empty). - users_to_check=$(yq -r '.jobs.authorize.steps[].env.USERS_TO_CHECK | select(. != null)' "$f" 2>/dev/null | head -1) - [ -n "$users_to_check" ] \ - || fail "$f: authorize job has no step setting USERS_TO_CHECK env var — the auth script needs at least one login to check (PR author, commenter, labeler, etc.)" - - # 7. Every non-authorize job has `needs: authorize` and a strict - # if-expression of exactly: needs.authorize.outputs.authorized == 'true' - # (whitespace normalized). Stricter than substring match — - # rules out tautologies like `... || true`. - other_jobs=$(yq -r '.jobs | keys | .[]' "$f" | grep -v '^authorize$' || true) - [ -n "$other_jobs" ] \ - || fail "$f: no non-authorize job found — workflow has nothing gated" - - for j in $other_jobs; do - needs=$(yq -r ".jobs.${j}.needs // \"\"" "$f") - [ "$needs" = "authorize" ] \ - || fail "$f: job '$j' must have 'needs: authorize' (got: $needs)" - - if_expr=$(yq -r ".jobs.${j}.if // \"\"" "$f") - # Normalize whitespace and quotes for the comparison. - normalized=$(echo "$if_expr" | tr -s ' ' | tr -d "\n") - expected="needs.authorize.outputs.authorized == 'true'" - [ "$normalized" = "$expected" ] \ - || fail "$f: job '$j' if: must be exactly \"$expected\" — no compound expressions, no tautologies (got: $if_expr)" - done - - echo " ✓ $f passed" -done - -echo "" -echo "All claude-*.yml workflows pass auth gate invariants." diff --git a/.github/workflows/auth-gate-invariants.yml b/.github/workflows/auth-gate-invariants.yml deleted file mode 100644 index 9ba9920ec..000000000 --- a/.github/workflows/auth-gate-invariants.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Auth gate invariants - -# Validates that the AI workflow auth gate structure is preserved. -# Runs on any PR that touches a `claude-*.yml` workflow file or this -# validator itself. -# -# This is a STRUCTURAL lint, not a semantic test. It catches the -# obvious attacks (delete the authorize job, drop the `needs:` -# dependency, broaden permissions, change the if-expression to a -# tautology). Subtle attacks (e.g., modifying the bash logic inside -# the auth check to admit everyone) are caught by CODEOWNERS review, -# which requires both @HarperFast/developers and @HarperFast/devops -# approval on `.github/` changes. -# -# Defense in depth — make this workflow a REQUIRED status check on -# `main` via branch protection so PRs can't merge without it passing. - -on: - pull_request: - paths: - - '.github/workflows/claude-*.yml' - - '.github/workflows/auth-gate-invariants.yml' - - '.github/scripts/validate-auth-gate-invariants.sh' - -jobs: - validate: - runs-on: ubuntu-latest - timeout-minutes: 2 - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Validate auth gate structure - run: bash .github/scripts/validate-auth-gate-invariants.sh diff --git a/.github/workflows/claude-issue-to-pr.yml b/.github/workflows/claude-issue-to-pr.yml index 8ffa32777..93b83349b 100644 --- a/.github/workflows/claude-issue-to-pr.yml +++ b/.github/workflows/claude-issue-to-pr.yml @@ -1,15 +1,16 @@ name: Claude Issue to PR -# Labeling an issue with `claude-fix:` kicks Claude off to -# investigate, make a focused change on a new branch, and open a PR -# linking back to the issue. The label's suffix scopes the ask (typo -# vs docs vs deps vs bug). +# Thin caller of the reusable in HarperFast/ai-review-prompts. The +# single `uses:` ref pin below controls everything that moves +# together — workflow logic, auth script, agent prompt, allowed- +# labels list. Bumping the pin is the entire upgrade motion. # -# Gated to HarperFast org members/collaborators: even though GitHub's -# permission model already restricts who can apply labels, we add an -# explicit author_association check on the issue author so mislabeling -# by an outsider can't trigger work. The action also performs its own -# write-access check on the labeler as a fallback. +# Pre-requisites (org-level secrets, configured once on HarperFast): +# - HARPERFAST_AI_CLIENT_ID +# - HARPERFAST_AI_APP_PRIVATE_KEY +# +# Plus the per-repo / inherited: +# - ANTHROPIC_API_KEY (required) on: issues: @@ -20,227 +21,30 @@ concurrency: cancel-in-progress: false jobs: - authorize: - # Single source of truth for "is this label trigger allowed to - # spawn Claude on this repo?". The `work` job below has ONE `if:` - # that depends on this — no step-level guards, no individual user - # list. - # - # Cheap pre-filter at job level: explicit whitelist of allowed - # labels (NOT `startsWith('claude-fix:')`, which would match typoed - # variants like `claude-fix:typos`). - # - # We check the LABELER (`github.actor`), not the issue author. The - # labeler must already have at least triage permission to apply a - # label; a maintainer labeling an external-author issue is a - # legitimate way to invoke the agent on community reports. - # - # Trust set: the @HarperFast teams listed in this repo's - # `.github/CODEOWNERS`. Defaults to `@HarperFast/developers` if the - # file is missing, empty, or has no HarperFast handles. External- - # org handles are deliberately ignored. - # - # Required (organization-level) secrets: - # - HARPERFAST_AI_CLIENT_ID (the App's Client ID, like Iv23li…) - # - HARPERFAST_AI_APP_PRIVATE_KEY (.pem file contents) - if: contains(fromJSON('["claude-fix:typo","claude-fix:docs","claude-fix:deps","claude-fix:bug"]'), github.event.label.name) - runs-on: ubuntu-latest - timeout-minutes: 1 - permissions: - contents: read # for the .github/CODEOWNERS fetch - outputs: - authorized: ${{ steps.check.outputs.authorized }} - steps: - - name: Mint org-read token - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 - with: - client-id: ${{ secrets.HARPERFAST_AI_CLIENT_ID }} - private-key: ${{ secrets.HARPERFAST_AI_APP_PRIVATE_KEY }} - owner: HarperFast - - - name: Checkout (for CODEOWNERS read) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github/CODEOWNERS - .github/scripts/authorize-claude-workflow.sh - sparse-checkout-cone-mode: false - - - name: Check labeler authorization - id: check - env: - DEFAULT_TOKEN: ${{ github.token }} - ORG_TOKEN: ${{ steps.app-token.outputs.token }} - ADMIT_CLAUDE_BOT: 'false' - USERS_TO_CHECK: ${{ github.actor }} - run: bash .github/scripts/authorize-claude-workflow.sh - work: - needs: authorize - if: needs.authorize.outputs.authorized == 'true' - runs-on: ubuntu-latest - timeout-minutes: 25 - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - - steps: - - name: Checkout - # Default shallow fetch (depth 1). The agent can commit and push on a - # shallow clone; `git log` / `git blame` aren't reached for by the - # current prompt. Bump to a deeper fetch only if we see the agent - # blocked on history lookups. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Clone shared Harper skills - # Pinned to a SHA so agent behavior is reproducible across runs — - # updates require an explicit pin bump here. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: HarperFast/skills - ref: d2db99bb37a6dde868cbc5ac81ca4146be8956fb # 1.3.0 (2026-04-16) - path: .harper-skills - - - name: Setup Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: '.node-version' - cache: 'npm' - - name: Install dependencies - run: npm ci - - - name: Claude (agent mode) - id: claude-agent - uses: anthropics/claude-code-action@ef50f123a3a9be95b60040d042717517407c7256 # v1.0.110 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - show_full_output: true - claude_args: | - --model claude-sonnet-4-6 - --max-turns 72 - # `--allowedTools` is convenience pre-approval, not a - # security boundary — see claude-mention.yml for the full - # rationale. Tools NOT listed below will still execute in - # CI; the entries here document the expected surface. - # - # Real containment for this workflow (issue-to-PR mode - # does AUTHORING work — branch creation, file edits, - # commits, push): - # * Token scope: `contents: write`, `pull-requests: write`, - # `issues: write`, `id-token: write`. Repo-scoped only. - # * Branch protection on protected refs (`main` / - # `release_*` / `v*.x`) — prevents pushes to those - # refs even with broader git access. - # * Auth gate — only trusted labelers (HarperFast team - # members) trigger the workflow at all. - # * Allowed labels list (`claude-fix:typo` / `:docs` / - # `:deps` / `:bug`) — narrow trigger surface. - # * Runner ephemerality. - # - # Notable absences in spirit (`Bash(npx:*)` deliberately - # not listed; `Bash(npm install)` no-arg) are prompt-level - # signals, not enforcement — same caveat as in - # claude-mention.yml. - --allowedTools "Read,Write,Edit,Grep,Glob,Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment:*),Bash(gh pr create:*),Bash(gh issue view:*),Bash(gh issue comment:*),Bash(git:*),Bash(npm install),Bash(npm ci:*),Bash(npm run:*),Bash(npm test:*)" - prompt: | - You were invoked because issue #${{ github.event.issue.number }} - on ${{ github.repository }} was labeled - `${{ github.event.label.name }}`. - - ## The ask - - Title: `${{ github.event.issue.title }}` - - Body (verbatim, including any multi-line content): - - ``` - ${{ github.event.issue.body }} - ``` - - Source: ${{ github.event.issue.html_url }} - - ## Label-scoped behavior - - The label suffix tells you how much latitude you have: - - - `claude-fix:typo` — a single-file typo, prose tweak, or - tiny doc fix. Should be one or two lines changed. - - `claude-fix:docs` — a documentation update. Code should - not be touched unless the doc is literally a code comment. - - `claude-fix:deps` — a dependency version bump. Update - `package.json` and regenerate the lockfile via - `npm install` + verify `npm ci` works. Update - `dependencies.md` too if a new runtime package is added. - - `claude-fix:bug` — a focused bug fix with at least one - test that fails before the fix and passes after. - - Any ask that requires judgment beyond the label's scope — - new public API, architecture changes, cross-cutting - refactors — is OUT of scope. In that case, comment on the - issue explaining what you see and do NOT open a PR. - - ## Conventions - - Read the repo's agent context files first (commonly - `CLAUDE.md`, `AGENTS.md`, or similar at the repo root). Their - conventions and gotchas apply. Match the repo's style. - - Harper-specific notes: - - Linter is **oxlint**, not eslint. `npm run lint` runs - oxlint. Don't add eslint config. - - Primary storage engine is RocksDB; LMDB is supported via - `HARPER_STORAGE_ENGINE=lmdb`. Changes affecting storage - should work on both. - - TypeStrip-compatible (`erasableSyntaxOnly`) — avoid - TypeScript features that break typestrip (non-type-only - type imports, parameter property initialization). - - `dependencies.md` documents all npm packages and their - justifications; keep it in sync with `package.json`. - - For docs and deployment-related changes, consult the shared - Harper skills at `.harper-skills/harper-best-practices/rules/`. - In particular, - `.harper-skills/harper-best-practices/rules/deploying-to-harper-fabric.md` - is authoritative for Harper's deployment model. Do NOT invent - generic production patterns (systemd units, raw Kubernetes, - cloud secrets managers, arbitrary .env recommendations) - without first checking whether a Harper-specific path exists - in those rules. - - ## Process - - 1. Create a branch named `claude/fix-${{ github.event.issue.number }}` - (or append `-` if useful). - 2. Make the change scoped to the label. - 3. Validate, scaling to the kind of change you made. - - - `claude-fix:typo` / `claude-fix:docs` (doc-only changes - to `*.md` — e.g. `README.md`, `CLAUDE.md`, `AGENTS.md`, - `dependencies.md` — or `package.json` keyword/description - fields): run `npm run format:check` and `npm run lint`. - Skip `npm run build` / `npm run test:unit` — they are - not affected and waste turns. - - `claude-fix:deps` / `claude-fix:bug` or any change that - touches code: run - `npm run build && npm run lint && npm run format:check && npm run test:unit`. - Fix anything that fails. Integration tests - (`npm run test:integration`) are slow — run only if the - change plausibly affects integration behavior. - - When in doubt, err toward the fuller validation. - 4. Commit with a descriptive message. - 5. Push the branch and open a PR via `gh pr create` with a - body that says `Closes #${{ github.event.issue.number }}`. - 6. Post a comment on the original issue linking to the PR. - - ## Must NOT - - - Do NOT push to `main` or any `release_*` / `v*.x` branch. - - Do NOT use REQUEST_CHANGES or APPROVE on any PR. - - Do NOT open a PR when the ask is out of scope — comment - and stop. - - Do NOT commit secrets, credentials, or large generated - artifacts (e.g. `node_modules/`, coverage output). + uses: HarperFast/ai-review-prompts/.github/workflows/_claude-issue-to-pr.yml@11872cb1cc2d0e90659659ade6d8ddbbdfbf1d05 # main 2026-05-06 (post #11/#12/#14) + with: + # Same SHA as the `uses:` ref above. See the comment in + # claude-mention.yml for why the duplication is unavoidable. + ai-review-prompts-ref: 11872cb1cc2d0e90659659ade6d8ddbbdfbf1d05 + repo-specific-conventions: | + ## Harper core notes + + - Linter is **oxlint**, not eslint. `npm run lint` runs + oxlint. Don't add eslint config or invoke `eslint` directly. + - Primary storage engine is **RocksDB**; LMDB is supported + via `HARPER_STORAGE_ENGINE=lmdb`. Changes affecting storage + should work on both. + - **TypeStrip-compatible** (`erasableSyntaxOnly`) — avoid + TypeScript features that break typestrip (non-type-only + type imports, parameter property initialization). + - **`dependencies.md`** documents all npm packages. For + `claude-fix:deps` work, update this file too if a new + runtime package is added. + - **Build tolerance (`tsc || true`)** is NOT used here — + Harper core's build should pass cleanly. Treat type errors + as real failures. + secrets: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + HARPERFAST_AI_CLIENT_ID: ${{ secrets.HARPERFAST_AI_CLIENT_ID }} + HARPERFAST_AI_APP_PRIVATE_KEY: ${{ secrets.HARPERFAST_AI_APP_PRIVATE_KEY }} diff --git a/.github/workflows/claude-mention.yml b/.github/workflows/claude-mention.yml index 5f3f0e311..1437f9942 100644 --- a/.github/workflows/claude-mention.yml +++ b/.github/workflows/claude-mention.yml @@ -1,10 +1,16 @@ name: Claude Mention Handler -# Responds to `@claude …` in PR comments and PR review (inline) comments. -# Claude enters the action's "agent mode": reads the commenter's request, -# uses the PR/issue as context, and can edit + commit + push. Gated to -# HarperFast org members/collaborators so external contributors can't -# trigger work against the repo. +# Thin caller of the reusable in HarperFast/ai-review-prompts. The +# single `uses:` ref pin below controls everything that moves +# together — workflow logic, parse + auth scripts, agent prompt. +# Bumping the pin is the entire upgrade motion. +# +# Pre-requisites (org-level secrets, configured once on HarperFast): +# - HARPERFAST_AI_CLIENT_ID (the App's Client ID, like Iv23li…) +# - HARPERFAST_AI_APP_PRIVATE_KEY (.pem file contents) +# +# Plus the per-repo / inherited: +# - ANTHROPIC_API_KEY (required) on: issue_comment: @@ -17,251 +23,36 @@ concurrency: cancel-in-progress: false jobs: - authorize: - # Single source of truth for "is this commenter allowed to trigger - # Claude on this repo?". The `work` job below has ONE `if:` that - # depends on this — no step-level guards, no individual user list. - # - # Cheap pre-filter at job level: comment must mention `@claude`. - # The first-non-whitespace-token precision check is in the work - # job's `Parse mention` step. - # - # Trust set: the @HarperFast teams listed in this repo's - # `.github/CODEOWNERS`. Defaults to `@HarperFast/developers` if the - # file is missing, empty, or has no HarperFast handles. External- - # org handles are deliberately ignored — we only admit HarperFast - # members. - # - # Required (organization-level) secrets: - # - HARPERFAST_AI_CLIENT_ID (the App's Client ID, like Iv23li…) - # - HARPERFAST_AI_APP_PRIVATE_KEY (.pem file contents) - if: contains(github.event.comment.body, '@claude') - runs-on: ubuntu-latest - timeout-minutes: 1 - permissions: - # contents: read for the .github/CODEOWNERS fetch via default token. - contents: read - outputs: - authorized: ${{ steps.check.outputs.authorized }} - steps: - - name: Mint org-read token - id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 - with: - client-id: ${{ secrets.HARPERFAST_AI_CLIENT_ID }} - private-key: ${{ secrets.HARPERFAST_AI_APP_PRIVATE_KEY }} - owner: HarperFast - - - name: Checkout (for CODEOWNERS read) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - sparse-checkout: | - .github/CODEOWNERS - .github/scripts/authorize-claude-workflow.sh - sparse-checkout-cone-mode: false - - - name: Check commenter authorization - id: check - env: - DEFAULT_TOKEN: ${{ github.token }} - ORG_TOKEN: ${{ steps.app-token.outputs.token }} - ADMIT_CLAUDE_BOT: 'false' - USERS_TO_CHECK: ${{ github.event.comment.user.login }} - run: bash .github/scripts/authorize-claude-workflow.sh - - work: - needs: authorize - if: needs.authorize.outputs.authorized == 'true' - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - # Write access is intentional — mention mode is "do work", which - # means editing files, committing, and pushing (either to the PR's - # branch or a new claude/… branch for issue-originated asks). - contents: write - pull-requests: write - issues: write - id-token: write - - steps: - - name: Checkout - # Default shallow fetch (depth 1). The agent can commit and push on a - # shallow clone; `git log` / `git blame` aren't reached for by the - # current prompt. Bump to a deeper fetch only if we see the agent - # blocked on history lookups. - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Parse mention - # Real precision gate (the job-level `if:` is a cheap pre-filter). - # Enforces: - # 1. `@claude` must be the FIRST non-whitespace token (word- - # boundary after) — rules out `@claudette`, inline prose - # mentions ("saw @claude's fix"), and quoted replies - # (`> @claude ...`) where the reply is addressing a human. - # 2. Case-insensitive word-boundary `deep` anywhere in the body - # → escalate to Opus. Sonnet is the default. - id: mention - env: - BODY: ${{ github.event.comment.body }} - run: bash .github/scripts/parse-claude-mention.sh - - - name: Clone shared Harper skills - # Pinned to a SHA so agent behavior is reproducible across runs — - # updates require an explicit pin bump here. - if: steps.mention.outputs.proceed == 'true' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - repository: HarperFast/skills - ref: d2db99bb37a6dde868cbc5ac81ca4146be8956fb # 1.3.0 (2026-04-16) - path: .harper-skills - - - name: Setup Node.js - # Needed so the agent can run `npm ci` / `npm run