Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
280 changes: 280 additions & 0 deletions .github/workflows/guardian-reusable.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
name: Guardian Review (reusable)

# Called by a repository's own guardian.yml on `/guardian review` (#739):
#
# jobs:
# review:
# if: github.event.issue.pull_request != null && github.event.comment.body == '/guardian review'
# uses: zaebee/codegraph-brain/.github/workflows/guardian-reusable.yml@<sha>
# with:
# pr_number: ${{ github.event.issue.number }}
# tooling_ref: <the same sha>
# source_root: src
# secrets:
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# GUARDIAN_APP_PRIVATE_KEY: ${{ secrets.GUARDIAN_APP_PRIVATE_KEY }}
# permissions: {contents: read, pull-requests: write, issues: write}
#
# `secrets: inherit` reaches a called workflow only within one organisation or
# enterprise; any other caller passes the secrets declared below by name.
# Variables are always the caller's: GUARDIAN_PROVIDER, GUARDIAN_MODEL,
# GUARDIAN_ANTHROPIC_EFFORT, GUARDIAN_FEATURES, GUARDIAN_SKEPTIC,
# GUARDIAN_SKEPTIC_MODEL, GUARDIAN_APP_CLIENT_ID.
#
# The caller's Actions policy must allow this workflow and the actions it uses
# (actions/*, astral-sh/setup-uv, peter-evans/create-or-update-comment).
# Pin `uses:` and `tooling_ref` to the SAME commit SHA, never a tag: the
# tooling runs on the caller's runner, and a tag can move.

on:
workflow_call:
inputs:
pr_number:
description: The pull request to review.
type: number
required: true
tooling_ref:
description: >-
The zaebee/codegraph-brain commit whose tooling runs. Pass the same ref
the caller's `uses:` names, so the job and its scripts agree.
type: string
required: true
source_root:
description: The directory inside the reviewed repository to build the graph from.
type: string
default: src
runner:
description: The job's runs-on, as JSON (a self-hosted label list works).
type: string
default: '["ubuntu-latest"]'
save_metrics:
description: >-
Restore guardian_metrics.jsonl from codegraph-brain's own data/guardian-metrics
branch. For codegraph-brain's caller only; any other caller leaves it false.
type: boolean
default: false
secrets:
ANTHROPIC_API_KEY:
required: false
GEMINI_API_KEY:
required: false
MISTRAL_API_KEY:
required: false
GUARDIAN_APP_PRIVATE_KEY:
required: false

permissions: {}

jobs:
review:
runs-on: ${{ fromJSON(inputs.runner) }}
# Below the app token's one-hour lifetime (#734): a review cannot outlive
# the token it posts with.
timeout-minutes: 45
permissions:
contents: read
pull-requests: write
issues: write

steps:
# Here rather than in each caller, so no caller can forget it.
- name: Check commenter has write access
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.comment.user.login,
});
const allowed = ['admin', 'write'];
if (!allowed.includes(perm.permission)) {
core.setFailed(`@${context.payload.comment.user.login} needs write access to trigger Guardian Review (has: ${perm.permission})`);
}

- name: React to trigger comment
env:
GH_TOKEN: ${{ github.token }}
COMMENT_ID: ${{ github.event.comment.id }}
run: |
gh api repos/$GITHUB_REPOSITORY/issues/comments/$COMMENT_ID/reactions \
-X POST -f content=eyes

- name: Validate inputs and get base branch
env:
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ inputs.pr_number }}
SOURCE_ROOT: ${{ inputs.source_root }}
GH_TOKEN: ${{ github.token }}
run: |
if [ "$EVENT_NAME" != issue_comment ]; then
echo "Call this on issue_comment (a /guardian review comment), not $EVENT_NAME" && exit 1
fi
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "Invalid PR number: $PR_NUMBER" && exit 1
fi
# The same rule as guardian_review.py: `..` only as a path component,
# no absolute path, drive, backslash or whitespace.
case "$SOURCE_ROOT" in /*) SOURCE_ROOT_BAD=1 ;; esac
case "/$SOURCE_ROOT/" in
*/../*|*\\*|*:*|*[[:space:]]*) SOURCE_ROOT_BAD=1 ;;
esac
case "${SOURCE_ROOT_BAD:-}" in
1)
echo "source_root must be a plain path inside the repository: '$SOURCE_ROOT'" && exit 1 ;;
esac
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
BASE_BRANCH=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json baseRefName --jq '.baseRefName')
echo "BASE_BRANCH=$BASE_BRANCH" >> $GITHUB_ENV

# The PR is data, never code (#730). Everything that runs — uv sync, the
# guardian scripts, cgis itself — comes from codegraph-brain at
# tooling_ref, checked out here; the PR lands in pr/ and is only diffed,
# read and parsed. This step and the next keep the job's provider keys
# out of reach of whoever wrote the PR.
- name: Checkout trusted tooling (codegraph-brain)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: zaebee/codegraph-brain
ref: ${{ inputs.tooling_ref }}
persist-credentials: false

# tooling_ref must name the commit the caller's `uses:` does: the steps
# here and the scripts they run then agree. An older checkout fails here,
# before any spend, instead of on an unknown flag.
- name: Check the tooling matches this workflow
run: |
grep -q -- '"--source-root"' scripts/guardian_review.py || {
echo "tooling_ref predates this workflow: pin uses: and tooling_ref to the same SHA" && exit 1
}

- name: Checkout PR branch as data
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: ${{ github.repository }}
# PR_NUMBER validated as digits-only in a previous step
ref: refs/pull/${{ env.PR_NUMBER }}/head
path: pr
fetch-depth: 0
persist-credentials: false

# The caller's GITHUB_TOKEN, passed for this one command through git's
# environment config (not argv, which other processes on a self-hosted
# runner can read) and never stored in pr/.git/config. Masked as
# actions/checkout masks it. A private repository needs it; a public one
# ignores it.
- name: Fetch base branch for diff
working-directory: pr
env:
FETCH_TOKEN: ${{ github.token }}
run: |
cred=$(printf 'x-access-token:%s' "$FETCH_TOKEN" | base64 | tr -d '\n')
echo "::add-mask::$cred"
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $cred" git fetch origin "$BASE_BRANCH"

- name: Install uv
uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3

- name: Install dependencies
run: uv sync --frozen --extra guardian

# diff_check (#733) reads data keys, which the graph has only when
# ingested with --data-keys; without the feature the graph is as before.
- name: Build code graph
env:
GUARDIAN_FEATURES: ${{ vars.GUARDIAN_FEATURES }}
SOURCE_ROOT: ${{ inputs.source_root }}
run: |
flags=""
# Same reading as parse_features: comma-separated, any whitespace ignored.
case ",$(printf '%s' "$GUARDIAN_FEATURES" | tr -d '[:space:]')," in
*,diff_check,*) flags="--data-keys" ;;
esac
uv run --frozen cgis ingest "pr/$SOURCE_ROOT" --output graph.db $flags

# Fetches anonymously: the tooling checkout keeps no credentials (#730),
# and the metrics branch is codegraph-brain's own. Off for other callers.
- name: Restore metrics from data branch
if: inputs.save_metrics
run: |
git fetch origin data/guardian-metrics 2>/dev/null && \
git show origin/data/guardian-metrics:guardian_metrics.jsonl \
> guardian_metrics.jsonl 2>/dev/null || true

# Post as the codegraph-brain GitHub App when it is configured (#734):
# set vars.GUARDIAN_APP_CLIENT_ID and secrets.GUARDIAN_APP_PRIVATE_KEY.
# With the variable unset this step is skipped and posting uses
# GITHUB_TOKEN, as before. With it set, a missing key, a wrong key or an
# app not installed here fails the job before any LLM call: no silent
# fallback to another identity. The app's key reaches every repository
# the app is installed on, so the token is narrowed to this one and to
# pull-requests: write. Two steps read it, both of which post: "Run
# Guardian Review", which posts the inline review itself, and the fallback
# comment. Both run only this repository's tooling, never PR code. It
# lives one hour; the job's timeout-minutes keeps the review inside that.
- name: Mint GitHub App token
id: app-token
if: vars.GUARDIAN_APP_CLIENT_ID != ''
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.GUARDIAN_APP_CLIENT_ID }}
private-key: ${{ secrets.GUARDIAN_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-pull-requests: write

- name: Run Guardian Review
id: guardian
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
GUARDIAN_PROVIDER: ${{ vars.GUARDIAN_PROVIDER }}
GUARDIAN_MODEL: ${{ vars.GUARDIAN_MODEL }}
# Thinking depth for an anthropic finder or skeptic; unset = the
# provider's default ("low"). #246 J measured the finder at "medium".
GUARDIAN_ANTHROPIC_EFFORT: ${{ vars.GUARDIAN_ANTHROPIC_EFFORT }}
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
Comment thread
zaebee marked this conversation as resolved.
GITHUB_REPOSITORY: ${{ github.repository }}
GUARDIAN_FEATURES: ${{ vars.GUARDIAN_FEATURES }}
GUARDIAN_SKEPTIC: ${{ vars.GUARDIAN_SKEPTIC }}
GUARDIAN_SKEPTIC_MODEL: ${{ vars.GUARDIAN_SKEPTIC_MODEL }}
SOURCE_ROOT: ${{ inputs.source_root }}
# GUARDIAN_EVIDENCE (#401) is deliberately not passed: the evidence
# pass runs `uv run mypy/ruff` inside the reviewed checkout, which is
# the PR, while this step holds the provider keys (#730).
# guardian_review.py refuses that combination with --repo-root.
run: |
uv run --frozen python scripts/guardian_review.py \
--output guardian_report.md \
--db graph.db \
--pr "$PR_NUMBER" \
--metrics guardian_metrics.jsonl \
--record-finder guardian_finder.json \
--inline \
--repo-root pr \
--source-root "$SOURCE_ROOT" \
--base-branch "$BASE_BRANCH"

- name: Upload metrics artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: guardian-metrics-${{ env.PR_NUMBER }}
path: guardian_metrics.jsonl
retention-days: 7

- name: Upload finder recording artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: guardian-finder-${{ env.PR_NUMBER }}
path: guardian_finder.json
retention-days: 7

- name: Post review as PR comment
if: steps.guardian.outputs.posted_inline != 'true'
uses: peter-evans/create-or-update-comment@a111a2c3bacd7be7898ee22d0d71d9aec2bb972c # v4
with:
token: ${{ steps.app-token.outputs.token || github.token }}
issue-number: ${{ env.PR_NUMBER }}
body-path: guardian_report.md
Loading
Loading