ci
CI/CD for a small shop API (app/) in two chained pipelines. ci takes GitHub push events, tests each commit to
main and builds green ones. When a commit breaks the tests, Claude Code fixes it in the build's checkout and the fix
is pushed to a branch for review. deploy releases green builds to production one at a time. A commit's
packet in ci is delivered only when production reports that commit live.
github (http, HMAC) ─┐
manual (push) ───────┴─ main? ─ checkout ─ bun test ─┬─ green ─ bun build ──────────────────────────┐
│ ├─ ship? ─▶ deploy ─▶ production
└─ red ─ Claude Code ─ bun test ─ push branch ─┘ (passed as pushed only)
▲ fixes it │ still red (max 2)
└─────────────┘
| Step | What it does |
|---|---|
github | A GitHub webhook: X-Hub-Signature-256 must be the HMAC of the body with CI_WEBHOOK_SECRET, or it's a 401 |
event, main | fn.fromPush reads the push. Pings, branch deletions and other branches are filtered out |
checkout | steps/checkout.ts fetches exactly that commit (shallow) into out/work/<packet id> |
test | steps/test.ts runs bun test there; verdict routes on the result |
build | bun build writes the release artifact, out/artifacts/<sha>/app.js |
fix | claude_code with its tools on, in the checkout, answering in fix.schema.json's shape. Its cost counts against agent_budget |
retest | bun test again. Still red: back to fix with the new output, at most twice more (loop), then the DLQ |
propose | steps/propose.ts commits the fix on autofix/<short>, pushes it, writes out/fixes/<short>.patch and out/reports/<short>.md |
ship | Only commits that passed as pushed go on. A fixed commit isn't what was pushed, so it stops here |
| output | Hands {sha, short, message, author} to deploy (to: pipeline). delivered: downstream waits until deploy has verified it live |
concurrency: 1, so builds run in push order and main reaches production in that order. What pipo packets ci
shows for each commit:
| State | Meaning |
|---|---|
delivered | Green, and live in production |
filtered | Not built (another branch, a ping), or red and fixed on autofix/<short> |
dead_lettered | Claude Code couldn't fix it in three tries (loop.max), or production refused the release |
Try it locally#
demo.ts stands in for GitHub: a git repository in out/repo whose commits send ci a signed push event, shaped
like GitHub's. deploy/prod-server.ts stands in for production. Claude Code must be installed and logged in
(claude auth login), or ci refuses to start and says so.
export CI_WEBHOOK_SECRET=$(openssl rand -hex 20) # before the engine starts: runners get the engine's environment
bun examples/deploy/prod-server.ts & # production, on 127.0.0.1:8795
bun pipo start examples/deploy/deploy.pipo
bun pipo start examples/ci/ci.pipo # the webhook listens on 127.0.0.1:8793
bun pipo ui # watch the packets move
bun examples/ci/demo.ts init # create out/repo and push its first commit: tested, built, live
curl -s localhost:8795/_version # {"sha": "…", …}
curl -s localhost:8795/quote -d '{"lines": [{"sku": "mug", "qty": 2, "price": 1250}], "code": "SAVE10"}'
bun examples/ci/demo.ts bug # commits a change that breaks the free-shipping test
cat examples/ci/out/reports/*.md # once Claude Code is done: what it found, its patch, the branch it pushed
cd examples/ci/out/repo && git merge autofix/<short> && cd - # merging is a new push to main: it goes live
bun examples/ci/demo.ts crash # passes the tests, but /health fails: production refuses it, the old release stays
bun pipo dlq ci # "passed CI but did not go live: … release refused, still serving …"
bun pipo push deploy --input rollback --data '{"sha": "<an earlier green sha>"}'
Any commit you make in out/repo is built too: its post-commit and post-merge hooks run demo.ts push.
With GitHub#
Add a webhook to the repository with payload URL https://<your host>/in/ci/github (a tunnel or reverse proxy to
the engine's gateway or the runner's port), content type application/json, the push event, and a secret. Give
secrets.webhook that secret as a reference, ideally op://<vault>/<item>/<field> in 1Password, rather than an
environment variable. To push autofix/* branches, the machine needs push access to the repository, through an SSH
key or a credential helper. Without it, the fix is still kept in out/fixes and the report says the push failed.
Replace prod-server.ts with your platform's deploy API in deploy.pipo's output, and its version endpoint in
delivered.with.url.
Claude Code runs as you, with its tools on (allow_tools: true), in a fresh checkout of the pushed commit. That's
fine for your own repositories. For code from people you don't trust, run the engine in a container or VM.
Test it#
bun pipo test examples/ci # agent and step results are stubbed (fixtures/), nothing runs
bun pipo test examples/deploy
bun test packages/runner/test/examples.test.ts -t ci # the whole story end to end, with a fake Claude Code
ci.pipo#
# CI for the shop API in ./app: every push to main is checked out and tested. A green build is built and handed to
# the deploy pipeline, and its packet is delivered only once that commit is live in production. A red build goes to
# Claude Code, which fixes it in the build's workspace; the tests run again (two more tries at most), and the fix is
# pushed to a branch, autofix/<short>, for a person to review. See README.md; docs/spec.md §3.3.1, §3.4, §3.14.
# export CI_WEBHOOK_SECRET=$(openssl rand -hex 20)
# bun pipo start examples/deploy/deploy.pipo && bun pipo start examples/ci/ci.pipo
# bun examples/ci/demo.ts init # a local repository whose commits send GitHub push events here
pipo: 1
name: ci
description: Test every push to main, deploy green builds, and have Claude Code fix red ones on a branch.
fn: ./ci.fn.ts
secrets:
webhook: env:CI_WEBHOOK_SECRET
# One build at a time, in push order, so main reaches production in the order it was pushed.
concurrency: 1
errors:
retry: 1
delay: 2s
then: dead_letter
inputs:
# A GitHub webhook (content type application/json, push events), signed with the shared secret.
github:
via: http
with:
path: /github
listen: 8793
auth:
hmac: {header: X-Hub-Signature-256, secret: "${secrets.webhook}", algorithm: sha256}
format: json
validate:
- type(data.ref) == "string" || exists(data.zen)
on_invalid:
respond: 422
message: "Not a GitHub push event: rule '${error.rule}' failed."
# Build any commit again: pipo push ci --input manual --data '{"repo": "…", "sha": "…"}'
manual:
via: push
validate:
- type(data.repo) == "string"
- type(data.sha) == "string" && matches(data.sha, "^[0-9a-f]{40}$")
nodes:
event:
label: Read the push
from: github
transform: fn.fromPush
main:
label: main only
from: [event, manual]
filter: exists(data.sha) && default(data.branch, "main") == "main"
checkout:
label: Check out
from: main
transform: exec
with:
command: bun
args: [./steps/checkout.ts, "${meta.packet_id}"]
stdin: "${json(data)}"
result: json
timeout: 2m
test:
label: bun test
from: checkout
transform: exec
with:
command: bun
args: [./steps/test.ts, "${meta.packet_id}"]
stdin: "${json(data)}"
result: json
timeout: 5m
verdict:
from: test
route:
green: data.tests.ok
red: else
# Green: build the release artifact, out/artifacts/<sha>/app.js.
build:
label: bun build
from: verdict.green
tap: exec
with:
command: bun
args: [build, ./src/app.ts, --target, bun, --outfile, "${data.artifact}/app.js"]
cwd: "${data.workspace}"
outputs: ["${data.artifact}/app.js"]
timeout: 2m
on_error: {then: dead_letter}
# Red: Claude Code works in the build's own checkout, with its tools on, and answers in fix.schema.json's shape.
fix:
label: Claude Code fixes it
from: verdict.red
agent: claude_code
with:
model: sonnet
cwd: "${data.workspace}"
allow_tools: true
timeout: 10m
schema: ./fix.schema.json
prompt: |
The tests fail on main at commit ${data.short} ("${data.message}" by ${data.author}).
You are in a checkout of that commit. `bun test` says:
${data.tests.log}
${meta.iteration > 0 ? "Your last change did not make them pass; the output above is from after it." : ""}
Find the cause and fix it with the smallest change that makes `bun test` pass. Fix the code, not the
tests, unless a test is plainly wrong. Run `bun test` to check. Don't commit, push or change git settings.
on_error: {retry: 0, then: dead_letter}
retest:
label: bun test again
from: fix
transform: exec
with:
command: bun
args: [./steps/test.ts, "${meta.packet_id}", --after-fix, "${meta.iteration}"]
stdin: "${json(data)}"
result: json
timeout: 5m
# Still red: back to Claude Code with the new output, twice at most; then a person has to look (the DLQ).
loop:
back_to: fix
until: data.tests.ok
max: 2
then: dead_letter
propose:
label: Push autofix branch
from: retest
tap: exec
with:
command: bun
args: [./steps/propose.ts]
stdin: "${json(data)}"
timeout: 1m
# Only a commit that passed as pushed is deployed. A fixed one ends here, filtered, with its branch pushed.
ship:
from: [build, propose]
filter: data.status == "passed"
output:
from: ship
to: pipeline
with:
pipeline: deploy
data:
sha: "${data.sha}"
short: "${data.short}"
message: "${data.message}"
author: "${data.author}"
on_error: {retry: 10, backoff: exponential, delay: 1s, max_delay: 30s}
# The commit counts as delivered only when deploy has verified it live, so `pipo packets ci` reads as a deploy log.
delivered:
check: downstream
within: 2m
on_fail:
then: dead_letter
message: "${data.short} passed CI but did not go live: ${error.message}"
stall:
after: 15m
then: notify
message: "CI is stuck: ${stall.pending} builds waiting, the oldest at '${stall.oldest.node}' for ${stall.oldest.age}."
# Claude Code reports what each fix costs.
agent_budget:
per_day: 5.00
warn_at: 80%
Source: examples/ci.