ci repair
It fixes the build it can explain
Most red builds are one of six things, and half of them are not your fault. buddy fix-ci reads the log, classifies the failure, checks whether the base branch is already broken, and repairs it when the fix is unambiguous — or tells you plainly that it is not.
$ buddy fix-ci --pr 128
__
(\,------'()'--o reading the failing run
(_ ___ /~" ci / test (ubuntu-latest)
(_)_) (_)_)
classified lockfile-drift
evidence "lockfile had changes, but
--frozen-lockfile was set"
base branch green — the failure is ours
mechanical yes
→ regenerating bun.lock
→ committed to buddy/update-react-18
→ posted the outcome on #128
fixed. attempt 1 of 3.
$ buddy fix-ci --pr 131
classified test-failure
evidence "expected 3, received 2"
"at src/queue/batch.test.ts:44"
base branch green
mechanical no
→ the failing assertion is about behaviour
this pull request changed on purpose.
Either the test or the change is wrong,
and choosing between them is yours.
reported. no commit made.
Six failures, told apart
Lock file drift, runner flake, install failure, type error, test failure and lint. Each has a signature matched against the log, and each carries the evidence lines that produced the verdict — so the classification is auditable rather than asserted.
Checks the base first
If the same failure happens on the base branch, the pull request did not cause it. Buddy says so instead of trying to repair somebody else's breakage on your branch.
Mechanical before model
Lock file drift is regenerated deterministically — no provider, no tokens, no guessing. The agent is only reached for failures that genuinely need reading the code.
A hard attempt limit
Prior attempts on the pull request are counted, and the run stops at the limit. A repair bot that retries forever is worse than a red build, because a red build is honest.
It commits, it does not merge
Repairs land on the working branch as an ordinary commit you review. fix-ci mode may write, run commands and use git — on that branch, in a workspace it cannot escape.
Explains itself either way
Every run posts an outcome: what it classified, what it did, and — when it declined — why the fix was not the bot's to make.
From a failing run
Repair is driven by the run that failed, because the diagnosis is a function of
that job's log. The workflow generated by buddy setup wires this up: when a
run on a Buddy branch fails, the fix-ci job reads its log and reports back on
the pull request.
From a comment
@buddy fix-ci on a pull request finds the run itself: the newest failing run
at the current head commit. Restricted to the head deliberately — a branch
keeps its old runs, and diagnosing one from three pushes ago would produce a
confident report about a failure somebody already fixed. If nothing at the head
is red, it says so rather than reaching further back.
What it does from there is bounded by where it runs. Answering a comment happens on the default branch, not the pull request's, so a lock-file rewrite or an agent edit would land on the wrong tree entirely. From a comment Buddy will:
- classify the failure and post the diagnosis
- tell you whether the base branch already fails the same way
- re-run a flake, which is an API call and needs no checkout
and it will name the repair it cannot apply rather than staying quiet about
it. The fix-ci job below does check out the branch, and repairs there.
Whose failure is it
Before anything else, Buddy asks whether the base branch is already failing the same way. Not is the base red — that would down tools on exactly the repositories that need the help most — but is it red in the same way: the newest run of each workflow on the base branch is classified from its own log, and compared against the failure at hand.
If they match, Buddy declines and says so. Repairing an inherited failure on a pull request attributes someone else's regression to that change, and hides the regression where nobody is looking for it.
Comparing classifications is an approximation, and it errs deliberately. Two
unrelated type errors both classify as type-error, so a genuine regression on
a base branch that already fails to type-check reads as inherited — Buddy
declines and explains, rather than committing a guess about code it has already
been told is broken.
This needs the ciRuns capability. A provider without it
still gets the full diagnosis; it just cannot tell an inherited failure from a
new one.
From the CLI
buddy fix-ci --run-id 12345 --pr 128
buddy fix-ci --run-id 12345 --pr 128 --dry-run # classify and report, change nothing
--run-id supplies the log to diagnose. --pr is where the report goes, and
also where the attempt counter lives — without it Buddy cannot tell a first
attempt from a fourth.
From a workflow
name: Buddy Fix CI
on:
workflow_run:
workflows: [CI]
types: [completed]
jobs:
repair:
if: github.event.workflow_run.conclusion == 'failure'
runs ubuntu-latest
permissions:
contents: write
pull write
actions: read
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bunx @buddysh/buddy fix-ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
How classification works
The log is matched against failure signatures before any model is involved. That ordering matters for cost and for trust: a stale lock file is a pattern, not a judgement call, and paying a language model to recognise one is silly.
| Kind | What it means | Repair |
|---|---|---|
lockfile-drift | The lock file is out of step with its manifest | Regenerated, committed and pushed |
flake | Network, rate limit or runner problem | Failed jobs re-run |
install | Dependencies could not be resolved | Agent, or reported |
type-error | The change does not type-check | Agent |
test-failure | An assertion failed | Agent, or reported |
lint | Lint or formatting violations | Agent |
unknown | Nothing recognisable | Reported, with the interesting log lines |
Re-running a flake
A transient failure needs no repair, only another go at the same commit — so
that is what Buddy does. It re-runs the failed jobs of the run it was handed
and says so in its comment. This needs actions: write, which the generated
workflow already grants; without it Buddy reports the diagnosis and points you
at the Actions tab.
The attempt counter is what keeps this from becoming a loop. A re-run that fails again brings Buddy straight back to the same pull request, and the count lives on the pull request precisely so it survives that.
Why a mechanical fix does not go green by itself
The lock-file repair commits to the branch and pushes it, but the pull request stays red. Two things combine to cause that, and neither has a way around it here:
- The job pushes with
GITHUB_TOKEN, which keeps the commit attributed to the bot rather than to whoever owns a personal access token — and GitHub deliberately does not start a workflow run for a push made with it. - Re-running the old run does not help either, because a re-run executes against the commit it originally ran on. That commit is the broken one.
So the fix lands, and the checks need re-running: from the Actions tab, or by the next push to the branch. Buddy tells you which of the two happened in the comment it leaves. This is the one case the re-run above cannot cover.
With no AI provider configured, classification and the mechanical repairs still work. You lose the agent-driven fixes, not the diagnosis.
What it is allowed to do
fix-ci is an agent mode drawing from the read, write, shell, git and comment tiers. Its limits are structural rather than advisory:
- Commands run from an empty environment plus an allowlist, so a command the agent runs cannot authenticate to your registry, your cloud or GitHub — the credentials are simply not there.
- Every path is resolved against the workspace and rejected if it escapes, checked twice so a symlink inside the workspace cannot satisfy the first check and land outside it.
- Tool calls, wall clock and tokens are each independently bounded.
Related
Conversations · Finishing touches · The agent runtime · Dependency updates