Skip to content

Wire the transaction check into CI on every push

You can run the report from a script. Now we let the script run itself - on every push that touches budgetcli, with no one pressing enter. This is the unattended check we set out to build: when a fresh batch of transactions lands, something recategorises them and runs the suite, so a malformed import gets caught at the door instead of corrupting months of history quietly.

Notice what’s load-bearing in that sentence: the suite. With no one pressing enter, the run’s claim to have worked is only as true as the checks it has to get past - that, more than any of the CI plumbing below, is what makes an unattended check worth trusting:

Nine chores, queued for an agent to run overnight. The terminal replays the night as it happened; the panel is the morning report - the only thing you’ll actually read. Watch what its checkmarks are made of, and when playback pauses, decide what the run has to get past before you believe it.

overnight batch - ledger-app
overnight batch · repo: ledger-app · 9 chores queued · nobody watching
the morning report

    The flaw rate here is theatrical - seven duds in nine - because the point is what each wiring would have caught, not the odds. The checks wear different clothes per tool (a CI step, a test command the loop must pass, a verifier agent), but the question is always the same: what does this run have to get past before “done” reaches you? And the residual is real: no check catches a requirement nobody wrote down.

    The command in the middle is still codex exec. CI only changes two things around it: how Codex authenticates when there’s no browser to sign in through, and what sandbox and approval posture it runs under when there’s no human to approve a pause.

    Auth in CI: the API key, not the sign-in flow

    Section titled “Auth in CI: the API key, not the sign-in flow”

    On your laptop you most likely signed in through the ChatGPT OAuth flow - codex login opens a browser, you click, you’re done. A CI runner has no browser and no one to click. So for unattended codex exec runs you authenticate with an API key instead. The raw CLI reads CODEX_API_KEY; the GitHub Action accepts an OPENAI_API_KEY secret through its openai-api-key input.

    The key must live in encrypted CI secrets, never in the workflow file or the repo. For GitHub Actions, prefer the first-party action: it keeps the key behind its API proxy instead of placing it in the environment of a shell step that can also run repository-controlled code.

    name: transaction-check
    on: [push]
    jobs:
    check:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
    with:
    node-version: 22
    cache: npm
    - name: recategorise with Codex
    uses: openai/codex-action@v1
    with:
    openai-api-key: ${{ secrets.OPENAI_API_KEY }}
    prompt: |
    Recategorise any transactions added in this push, then run the test suite.
    Report the result clearly; the workflow checks the suite separately.
    - name: run the deterministic test suite
    run: npm test

    Two details in that workflow earn their keep. First, the deterministic test command remains a normal CI step, so a failing suite produces the workflow’s actual failure status rather than relying on a natural-language instruction to make Codex choose an exit code. Second, the first-party action owns the Codex install and key handoff; its version and security behavior are reviewed as part of the action upgrade process.

    For other CI systems that invoke the raw CLI, set CODEX_API_KEY only for the single codex exec invocation and do not run untrusted repository code in that same credentialed process environment. In GitHub Actions, keep using the action shown above. If you do use a raw shell step, treat the key as exposed to any tests, build scripts, dependency hooks, or other repository-controlled commands in that step.

    The posture an unattended run has to commit to

    Section titled “The posture an unattended run has to commit to”

    Here is where Approvals & sandboxing stops being optional. Interactively, the two axes - -a / --ask-for-approval and -s / --sandbox - lean on you: when the agent wants to do something its sandbox forbids, the approval mode decides whether it runs, asks, or refuses, and you answer the ask. In CI there’s no one to answer. So you have to pick a posture where “ask” never happens, because an ask in an unattended run is a hang or a death, not a pause.

    That rules out on-request here - it’s built to pause and consult you. The combination an unattended run wants is approvals set to never, paired with the tightest sandbox that still lets the job finish:

    codex exec "Recategorise new transactions, then run the suite." \
    --ask-for-approval never \
    --sandbox workspace-write
    • --ask-for-approval never means the run never stops to consult a human - it either does the work within its sandbox or it fails. That’s the only approval mode that makes sense when there’s no human to consult.
    • --sandbox workspace-write confines writes to the working directory, so the run can recategorise transactions and write test output but can’t roam the runner’s filesystem. Recall from the sandbox lesson that network is off by default in workspace-write - which is exactly what you want for a check that should only ever touch local files, and a reason to keep the exchange-rate MCP server out of this particular job.

    The principle is the same high-floor, low-ceiling posture you built interactively, now carrying full weight: never approvals so the run can’t hang, the narrowest sandbox so it can’t wander. Reserve danger-full-access and --yolo for throwaway containers; they have no business on a runner that has your repo checked out and your API key in its environment.

    Treat the new transactions as untrusted input

    Section titled “Treat the new transactions as untrusted input”

    One CI-specific hazard worth a callout. The data this job runs on - freshly imported transactions, possibly from a CSV someone else produced - is input the agent will read, and input is where prompt injection lives. A row crafted to read like an instruction (“ignore previous instructions and…”) is exactly the kind of thing an unattended agent shouldn’t be free to act on.

    Two habits keep that contained, both already in your toolkit:

    • Lean on the sandbox, not the prompt, for the hard limits. --sandbox workspace-write with network off means that even if the agent were talked into something, it physically can’t reach the rate API or exfiltrate anything off the box. The sandbox is the wall; the prompt is just guidance.
    • Hand it data, don’t let it hunt. Where you can, pipe or point the agent at the specific transactions to check rather than turning it loose to find them - the headless lesson showed the shape. Less reaching, smaller blast radius.

    You now have the transaction check running on every push, authenticating with a key it reads from the environment and confined to a posture it committed to before the run started. But there’s a subtler problem hiding in that run: it still loads your personal config and budgetcli’s AGENTS.md, which means it can behave differently on the runner than it did on your laptop - and differently next month when either of those files has drifted. Pinning that down is the next lesson.