After this article you'll have a GitHub Actions workflow that triages every new issue with Claude, a nightly job that rewrites your stale changelog from real commits, and a claude CLI step that auto-fixes failing lint on a PR branch and pushes the patch back. All three are copy-paste runnable today, and I'll show you the exact line that cost me $14 in wasted API calls before I caught it.
Why I moved Claude Code from my laptop into GitHub Actions CI
The blunt conclusion first: running Claude Code interactively in your terminal is great for building, but terrible for chores. Chores happen when you're asleep, in a meeting, or context-switched onto something else. I was spending roughly 70 minutes a day on three things — labeling issues, writing release notes, and fixing the same trivial lint failures — and none of it needed my brain.
The unlock is that @anthropic-ai/claude-code ships a non-interactive mode. claude -p "prompt" runs headlessly, prints to stdout, and exits with a status code. That's the entire bridge between "AI assistant" and "cron job". Once it runs headlessly, GitHub Actions becomes a free scheduler with secrets management, a checkout of your repo, and write access to your PRs already wired up.
My real numbers after three weeks: 6.2 hours/week reclaimed, about $9/month in Claude API spend (I'm on a metered key for CI, separate from my Max plan), and one embarrassing incident I'll get to in the pitfalls section.
Workflow 1: Triaging GitHub issues with the Claude CLI in under 30 seconds
The most boring chore is the highest-value to automate, because it happens on someone else's schedule. Every new issue used to sit unlabeled until I got to it. Now Claude reads the issue body, picks from my actual label set, and posts a one-paragraph triage comment.
The key design choice: I pass Claude the real label list from the repo via gh, so it can't hallucinate priority: P0 when my labels are prio/high. Grounding the model in real data is the difference between a useful bot and a noisy one.
# .github/workflows/triage.yml
name: Claude issue triage
on:
issues:
types: [opened]
permissions:
issues: write
contents: read
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: Triage with Claude
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BODY: ${{ github.event.issue.body }}
NUM: ${{ github.event.issue.number }}
run: |
LABELS=$(gh label list --limit 100 --json name -q '.[].name' | paste -sd ',')
SUGGESTION=$(claude -p "You are triaging a GitHub issue. \
Available labels (pick 1-3 ONLY from this list): $LABELS. \
Issue text: $BODY. \
Reply with a JSON object: {\"labels\": [...], \"comment\": \"one short paragraph\"}" \
--model claude-haiku-4-5-20251001 --output-format json | jq -r '.result')
echo "$SUGGESTION" | jq -r '.labels[]' | while read -r L; do
gh issue edit "$NUM" --add-label "$L" || true
done
echo "$SUGGESTION" | jq -r '.comment' | gh issue comment "$NUM" --body-file -
Note I use claude-haiku-4-5-20251001 here, not Opus. Triage is a classification task — Haiku does it for roughly a tenth of the cost and finishes in ~4 seconds. Reserve the expensive models for jobs that actually write code. This single decision dropped my triage cost from ~$0.03 to ~$0.004 per issue.
Workflow 2: A nightly Python job that rebuilds CHANGELOG.md from git commits
Everyone's changelog rots. Mine had a 5-week gap. Instead of a git hook nobody runs, I schedule a 2 a.m. job that diffs the last 24 hours of commits and asks Claude to summarize them into human-readable release notes — then opens a PR so I review before anything merges.
The interesting bit is grounding again: I don't ask Claude "what changed lately?" I hand it the commits and forbid invention. Here's the Python that builds the prompt and calls the SDK directly (cleaner than shelling out when you need to parse structured input):
# scripts/changelog.py
import subprocess, datetime, os
from anthropic import Anthropic
def commits_since(hours=24):
since = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(hours=hours)).isoformat()
out = subprocess.run(
["git", "log", f"--since={since}", "--no-merges",
"--pretty=format:%h %s"],
capture_output=True, text=True, check=True,
)
return out.stdout.strip()
def summarize(commits: str) -> str:
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
msg = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=800,
messages=[{
"role": "user",
"content": (
"Turn these git commits into changelog bullets grouped "
"under ### Added / ### Fixed / ### Changed. "
"Use ONLY information present in the commit messages "
"— do not invent features. Commits:\n\n" + commits
),
}],
)
return msg.content[0].text
if __name__ == "__main__":
commits = commits_since(24)
if not commits:
print("No commits in window; skipping.")
raise SystemExit(0)
notes = summarize(commits)
today = datetime.date.today().isoformat()
with open("CHANGELOG.md", "r+", encoding="utf-8") as f:
old = f.read()
f.seek(0)
f.write(f"## {today}\n\n{notes}\n\n{old}")
And the workflow that runs it and opens a PR — peter-evans/create-pull-request handles the branch and commit so you keep a human review gate:
# .github/workflows/changelog.yml
name: Nightly changelog
on:
schedule:
- cron: "0 17 * * *" # 02:00 JST
workflow_dispatch:
jobs:
changelog:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history or --since lies to you
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install anthropic
- run: python scripts/changelog.py
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- uses: peter-evans/create-pull-request@v6
with:
commit-message: "docs: nightly changelog"
branch: auto/changelog
title: "Nightly changelog update"
fetch-depth: 0 is not optional. The default shallow checkout grabs one commit, so git log --since returns almost nothing and your changelog silently stays empty. I lost two nights to a "working" job that produced blank PRs because of this.
The $14 mistake: how a claude auto-fix loop billed me on every push
Here's the failure I promised. My third workflow auto-fixes lint on a PR: it runs ruff, and if it fails, hands the diff to Claude to fix and pushes the result. The naive version triggered on: push. Claude pushes a fix → that push triggers the workflow again → ruff still flags one stylistic rule Claude and the linter disagree on → Claude "fixes" it again → push → loop. It ran 9 times in 4 minutes before I killed it, billing an Opus call each round. $14, gone, on a missing-newline argument between two robots.
Two guards fixed it permanently. First, skip the job when the actor is the bot itself. Second, only push if the working tree actually changed:
# .github/workflows/autofix.yml
name: Claude lint autofix
on:
pull_request:
types: [opened, synchronize]
jobs:
autofix:
if: github.actor != 'github-actions[bot]' # break the loop
runs-on: ubuntu-latest
permissions: { contents: write, pull-requests: write }
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.head_ref }} }
- run: npm install -g @anthropic-ai/claude-code
- run: pip install ruff
- name: Fix lint with Claude
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
if ! ruff check . ; then
claude -p "ruff check failed. Read the ruff output above, \
edit the offending files to fix ONLY the lint errors, \
and make no behavioral changes." \
--model claude-sonnet-4-6 --permission-mode acceptEdits
fi
- name: Commit only if changed
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --quiet ; then
git commit -am "style: claude lint autofix"
git push
else
echo "No changes — not pushing."
fi
The --permission-mode acceptEdits flag is what lets Claude Code edit files unattended without an interactive approval prompt; without it the CLI hangs forever in CI waiting for a keystroke. The git diff --quiet check is your circuit breaker — if Claude decided the code was already fine, you push nothing and the cycle dies.
What to automate next, and the one rule that keeps it safe
After three weeks the pattern that generalizes: anything where the input is text the model can be grounded in (an issue body, a commit range, a linter's stderr) and the output is reviewable (a comment, a PR, a labeled item) is a great candidate. Anything that writes to production without a human gate is not — every one of my workflows ends in a PR or a comment, never a direct merge or deploy.
Pick the model per job: Haiku for classification, Sonnet for code edits, Opus only when you genuinely need deep reasoning. Cap your CI key with a monthly budget in the Anthropic console so a runaway loop costs you $14 of annoyance instead of a $400 invoice. Start with the issue triage workflow above — it's the lowest risk, ships value on day one, and proves out your secrets setup before you let the model touch code.
Top comments (0)