pipo. docs

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)
                                                               └─────────────┘
StepWhat it does
githubA GitHub webhook: X-Hub-Signature-256 must be the HMAC of the body with CI_WEBHOOK_SECRET, or it's a 401
event, mainfn.fromPush reads the push. Pings, branch deletions and other branches are filtered out
checkoutsteps/checkout.ts fetches exactly that commit (shallow) into out/work/<packet id>
teststeps/test.ts runs bun test there; verdict routes on the result
buildbun build writes the release artifact, out/artifacts/<sha>/app.js
fixclaude_code with its tools on, in the checkout, answering in fix.schema.json's shape. Its cost counts against agent_budget
retestbun test again. Still red: back to fix with the new output, at most twice more (loop), then the DLQ
proposesteps/propose.ts commits the fix on autofix/<short>, pushes it, writes out/fixes/<short>.patch and out/reports/<short>.md
shipOnly commits that passed as pushed go on. A fixed commit isn't what was pushed, so it stops here
outputHands {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:

StateMeaning
deliveredGreen, and live in production
filteredNot built (another branch, a ping), or red and fixed on autofix/<short>
dead_letteredClaude 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.

Generated from the Pipo source code.Pipo is licensed under the AGPLv3.