Skip to content
Latchkey LogoLatchkey home

How to run CI from Claude Code

To run CI from Claude Code you have three honest routes: trigger the GitHub Actions workflow with gh and watch it, replay the workflow steps locally with act, or run the test command itself on a fresh runner and stream the log back into the session. Which one you want depends on whether you are testing the workflow, the code, or the machine the code runs on.

Three routes from an agent session to a CI answer: gh dispatch, act locally, a fresh runner
The three routes answer different questions. Pick by what you are unsure about: the workflow file, the code, or the machine.
Recorded runner log: node v20.20.2 on Linux, TAP output reporting 2 tests passed, 0 failed
The recorded run from content/repro/run-github-actions-from-claude-code.sh on a Latchkey latchkey-small runner, 2026-09-20.

The agent has just finished an edit and the only question that matters is whether CI passes. Pushing a branch to find out is a slow answer: you spend a commit, a workflow run and several minutes of wall clock before the first line of output comes back, and if the answer is no you do it again.

Three routes shorten that loop, and they answer different questions. This page gives each one a command you can paste, says what it proves and what it does not, and ends with the reverse direction: Claude Code running inside GitHub Actions rather than driving it from outside.

Route 1: dispatch the workflow and watch it from the shell

If the thing you are unsure about is the workflow itself, run the real workflow. The GitHub CLI is already in most agent sessions, and it turns a workflow run into a foreground command with an exit code, which is the shape an agent can branch on.

gh workflow run needs the workflow to declare on: workflow_dispatch; without that trigger GitHub refuses the dispatch. The run id is not returned reliably by the dispatch call, so the second line looks it up, and gh run watch --exit-status then blocks until the run finishes and exits nonzero if it failed. Ask for the failed log only, not the whole run: a full Actions log is tens of thousands of lines and most of it is setup noise the agent will read as context it has to reason about.

The lookup is the fragile part: a dispatch takes a moment to appear in the run list, so a query issued immediately can return the previous run. Filter by branch, and check that the run you watched is the one you started.

Terminal
gh workflow run ci.yml --ref "$(git branch --show-current)"
sleep 3
run_id=$(gh run list --workflow ci.yml --branch "$(git branch --show-current)" \
  --limit 1 --json databaseId --jq '.[0].databaseId')
gh run watch "$run_id" --exit-status --compact
gh run view "$run_id" --log-failed

Route 2: replay the steps locally with act

act reads your workflow files and runs each step in a Docker container on your own machine. It is the fastest way to find out whether the YAML is shaped correctly: whether the job matrix expands the way you meant, whether an expression evaluates, whether step ordering and if conditions do what you expect. It needs Docker running, because that is how it executes every step.

What it does not give you is the GitHub-hosted runner. act's own documentation is blunt about it: "These default images do not contain all the tools that GitHub Actions offers by default in their runners", and "Many things can work improperly or not at all while running those image". A job that passes under act can still fail on ubuntu-latest because the preinstalled toolchain, the disk and the memory ceiling are all different, and a job that fails under act is sometimes only missing a tool the real image has.

Treat a green act run as evidence about the workflow file, not about your code or the environment.

Terminal
act -l
act -j test
act pull_request -W .github/workflows/ci.yml

Route 3: run the command itself on a fresh runner

Most of the time the agent does not want to know whether the workflow file parses. It wants to know whether the tests pass on a clean Linux machine with a fresh dependency install, which is the thing that differs from the laptop and the thing that breaks. Running the command directly skips the workflow layer entirely.

latchkey run packs the working tree, ships it to a fresh runner, executes one bash line, streams the log back to your terminal and exits with the job's outcome. That status is the verdict, so keep it: a pipe replaces it with the parser's status, and the complete event's own exit_code field reads a cancelled or expired job as an ordinary test result. There is no branch to push and no workflow run to spend.

The full flag list, what the runner does with the upload and what it costs are on run tests on a fresh runner from your terminal.

Terminal
latchkey run 'npm ci && npm test'
verdict=$?   # the CLI's own exit status is the verdict
latchkey run --size large --timeout 3600 'npm ci && npm run build && npm test'
# Reading the JSON stream without throwing that status away:
latchkey run --output json 'npm test' >events.ndjson
verdict=$?
jq -r 'select(.event == "complete") | "\(.state) \(.exit_code)"' <events.ndjson

The run this page was written from

Route 3 is the one with a recording behind it. content/repro/run-github-actions-from-claude-code.sh writes a two-test fixture and runs node --test on a Latchkey latchkey-small runner. The harness invoked the CLI with --no-context, so the runner started with an empty workspace and the script created its own fixture; a reader running the same loop from a checkout gets their tree packed and uploaded instead.

What came back, verbatim, is below. The exit code was 0, and that single number is what an agent branches on: the log is for the human or for the next turn if the number is not zero.

latchkey run, recorded 2026-09-20 on latchkey-small
runner: Linux 6.17.0-1019-aws, node v20.20.2
workspace: /home/runner/fixture
TAP version 13
# Subtest: adds two numbers
ok 1 - adds two numbers
  ---
  duration_ms: 1.247866
  ...
# Subtest: is commutative
ok 2 - is commutative
  ---
  duration_ms: 0.141422
  ...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 65.953254

Which route answers which question

Pick by what you are actually unsure about. Running all three on every edit is how a fast loop becomes a slow one.

What you are unsure aboutRouteWhat it costs
The workflow file: triggers, matrix, expressions, step orderactDocker on your machine, no Actions minutes, no runner parity
The real pipeline end to end, including secrets and permissionsgh workflow run plus gh run watchOne workflow run and its Actions minutes
The code and the machine: does the suite pass on clean Linuxlatchkey runRunner minutes at $0.0025 per minute on latchkey-small, no commit

The other direction: Claude Code inside GitHub Actions

Everything above runs the agent on your machine and CI somewhere else. The claude-code-action integration inverts that: Claude Code runs inside a GitHub Actions job, on a runner, triggered by a GitHub event. Anthropic's documentation, read on 2026-09-20, describes two modes. Without a prompt input the action waits for the trigger phrase, @claude by default, in an issue or pull request comment. With a prompt input it runs automatically on any event, including a schedule.

The workflow below is the minimal interactive version from those docs. The parts that are not boilerplate are the permissions: id-token: write for the action's default GitHub App authentication, actions: read so Claude can read CI results on the pull request, and the if condition so a runner does not start on every comment.

One consequence is worth knowing before you wire it up. GitHub does not trigger workflows on commits made with the default GITHUB_TOKEN, so if you pass that token to the action, CI will not run on the commits Claude pushes. The documentation names this in its troubleshooting section, and the fix is to let the action authenticate as the GitHub App instead.

.github/workflows/claude.yml
name: Claude Code
on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
jobs:
  claude:
    if: contains(github.event.comment.body, '@claude')
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      issues: write
      id-token: write
      actions: read
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 1
      - uses: anthropics/claude-code-action@v1
        with:
          anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

What to hand back when the job fails

An agent reading CI output is spending context on it, so hand it the smallest thing that answers the question. The verdict first, the failing step's log second, the rest never. gh run view --log-failed does that for a GitHub run; latchkey run --output json emits NDJSON whose complete event carries the job state beside the recorded exit code, which is context rather than the verdict.

The failure worth studying is the one that is not about your code at all. A registry timeout, a full disk or an out-of-memory kill is a fact about the machine, and feeding that log to an agent invites it to edit code that was never wrong. Self-healing CI explained draws that line, and the failure pages behind it, such as exit code 137 in GitHub Actions, show what each one looks like in a log.

Key takeaways

  • act tests the workflow file, gh workflow run tests the real pipeline, latchkey run tests the code on a clean machine.
  • act does not ship GitHub's runner image, so a green act run says nothing about the hosted environment.
  • Watch a dispatched run with gh run watch --exit-status so the agent gets an exit code rather than prose.
  • claude-code-action is the reverse direction: the agent inside Actions, triggered by @claude or by a prompt input.

Frequently asked questions

Can I run GitHub Actions workflows locally before pushing?
Partly. act runs each step of your workflow in a Docker container, which checks the workflow file end to end, but its images are not GitHub's runner images and its own documentation says they do not contain all the tools GitHub ships. To check the code rather than the YAML, run the test command on a fresh Linux runner instead.
How do I test AI-generated pull requests in CI?
The same way as any other pull request, with one addition: run the suite before the branch exists. An agent that runs latchkey run on its working tree finds the failure in the loop where it can still fix it silently, so the pull request it eventually opens is one that has already passed on a clean machine.
How can I debug flaky tests and intermittent CI failures?
Run the same command repeatedly on fresh machines and compare. A flake that reproduces one time in five on clean runners is a real race in your code or your fixtures; one that never reproduces there, but fails in CI, is usually about the environment, such as memory, disk or a registry.
Why does CI not run on the commits Claude pushes?
Because GitHub does not trigger workflows on commits made with the default GITHUB_TOKEN. Anthropic's GitHub Actions documentation names this in its troubleshooting section: remove github_token: ${{ secrets.GITHUB_TOKEN }} from the action step so it authenticates as the Claude GitHub App, or pass a custom app token.

Related guides

References

An agent wants an exit code, not a run page. Latchkey runs the command on a fresh runner and returns one. Start free → 30-day trial · No credit card