The design question
I wanted Claude Code in my CI pipeline, but "add AI to CI" is far too vague to build against. An unrestricted agent that reviews, fixes, and answers comments all in one workflow is impossible to scope, and fixing a lint error shouldn't draw on the same budget as an architectural review. On top of that, any workflow that pushes commits has to answer one uncomfortable question up front: what stops it from looping forever.
So I split the answer into three separate GitHub Actions workflows, each with its own trigger, permissions, and constraints.
Tier 1: read-only PR review
Triggers on ready_for_review. Posts a single structured comment and changes nothing:
on:
pull_request:
types: [ready_for_review]
permissions:
contents: read
pull-requests: write
id-token: writeThe prompt is blunt about it: "Do NOT make any code changes." It reviews against a project-specific checklist (accessibility, performance, architecture, TypeScript strictness) and spits out a verdict with file:line references.
It skips bot PRs (Renovate, Dependabot) and any PR targeting main, and the review runs against the full diff from origin/develop...HEAD, not just the latest commit.
Tier 2: constrained auto-fix
Triggers when CI fails on a PR branch, then diagnoses and fixes only the mechanical failures:
on:
workflow_run:
workflows: [CI]
types: [completed]The scope is deliberately rigid: lint auto-fix, formatting, typecheck errors from renames, and snapshot updates, and nothing else. No behavior changes, no test-logic edits, no architectural decisions. If a failure needs actual judgment, it leaves a PR comment explaining what it found and stops there.
The loop guard is the critical piece here. Without it, auto-fix pushes a commit, CI re-runs, fails again, and auto-fix pushes again, on and on. So I check the last five commits for a prior fix(ci): from github-actions[bot]:
const commits = await github.rest.pulls.listCommits({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.workflow_run.pull_requests[0]?.number,
per_page: 5,
});
const hasAutoFix = commits.data.some(
c =>
c.commit.message.startsWith('fix(ci):') &&
c.commit.author?.name === 'github-actions[bot]'
);One attempt per CI failure, and if the fix doesn't resolve it, a human takes a look.
Visual regression snapshots get their own step: if the auto-fix step already committed, skip; otherwise run Playwright with --update-snapshots and commit the new baselines. It's separate because snapshot updates need a full build and browser install that the lint/format step doesn't.
Tier 3: interactive @claude
Triggers on PR comments containing @claude. This is the escape hatch for ad-hoc tasks: "refactor this function," "add a test for this edge case," "explain why this fails":
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]There's no prompt template here; the comment itself is the instruction. This tier gets contents: write because someone is explicitly asking for code changes.
The input validation lesson
The first deployment fell over immediately. The claude-code-action@v1 action doesn't accept max_turns, timeout_minutes, or direct_prompt as inputs; the actual inputs are prompt (not direct_prompt) and claude_args (for flags like --max-turns 20), and it also requires id-token: write for OIDC authentication.
No amount of reading the README would have saved me here; the action's action.yml is the real source of truth for valid inputs. I ended up reading the error output, cross-referencing the valid input list, and fixing all three workflows in a single commit.
Takeaway
Scope AI in CI the way you'd scope any other automation: by trigger, by permissions, and by what it's allowed to change. Read-only review, constrained auto-fix, and interactive assistance are three different trust levels, and folding them into one workflow tangles up "should this agent read or write?" with "is this agent reacting to a failure or to a human?"
