Course · Codex · Automation
Wire the transaction check into CI on every push
The report job still had you at one end of it - you typed the command, even if nobody watched what happened after. The transaction check removes that too: it fires on every push that touches budgetcli, and the first thing that has to change is who’s allowed to start it. Here’s the tension this lesson turns on: every power an unattended run might need has to be granted before the run exists, because there’s nobody there to grant one mid-run - grant too little and it dies for a reason that isn’t a mistake, grant too much and the one time it’s actually wrong, it has room to be wrong in.
Thirty seconds on your own setup, before the arithmetic
Section titled “Thirty seconds on your own setup, before the arithmetic”If you already have codex exec wired into a cron job or a CI workflow somewhere, open it now. Look for two things: an explicit --ask-for-approval, and an explicit --sandbox. If both are already there in writing, you’ve already made the call this lesson argues for - skim ahead to the honesty section, where the posture stops mattering and what the exit code is actually worth starts mattering more. If either is missing, the run is inheriting a default from whatever machine happens to execute it, and you don’t yet know what that default grants.
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 likely signed in through the ChatGPT OAuth flow - codex login opens a browser, you click, done. A CI runner has no browser and no one to click. So unattended codex exec runs authenticate with an API key instead: the raw CLI reads CODEX_API_KEY, and the GitHub Action accepts an OPENAI_API_KEY secret through its openai-api-key input.
The key lives in encrypted CI secrets, never in the workflow file or the repo. Prefer the first-party GitHub Action - it keeps the key behind its own API proxy instead of placing it in the environment of a shell step that can also run repository-controlled code:
name: transaction-checkon: [push]jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" cache: pip - run: pip install -e ".[dev]" - 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: pytestThe action’s name, its version tag, and the exact input and environment-variable names are current as of this writing and are the first things to check against the action’s own docs before you pin them; the shape of the job is what to take from this, not the strings.
Two details earn their keep. The deterministic test command stays a normal CI step, so a failing suite produces the workflow’s own failure status instead of trusting a natural-language reply to pick the right exit code. And the first-party action owns the Codex install and the key handoff, so its version and security behaviour get reviewed as part of upgrading the action, not reinvented in every workflow file. If you invoke the raw CLI from another CI system instead, set CODEX_API_KEY only for the single codex exec step, and don’t run untrusted repository code - tests, build scripts, dependency hooks - in that same credentialed process.
The posture, granted before the run exists
Section titled “The posture, granted before the run exists”This is where Approvals & sandboxing stops being a convenience and becomes the thing that grants the move, because nobody’s there to ask. Interactively, the approval mode decides whether a consequential move runs, asks, or refuses - and you answer the ask. In CI there is no one to answer, so “ask” can’t be one of the options. Set it to anything that pauses and the run either hangs forever or dies waiting, and neither is the failure you wanted.
codex exec "Recategorise new transactions, then run the suite." \ --ask-for-approval never \ --sandbox workspace-write--ask-for-approval nevermeans the run never stops to consult a human - it either does the work inside its sandbox or it fails. It’s the only approval mode that makes sense with nobody to consult.--sandbox workspace-writeconfines writes to the working directory, so the run can recategorise transactions and write test output but can’t roam the runner’s filesystem. Network is off by default inworkspace-write- exactly right for a check that should only ever touch local files, and one more reason to keep the exchange-rate MCP server out of this particular job.
Every consequential move that interactive Auto would have paused on - reaching outside the project, onto the network, or anything else hard to walk back - this posture either refuses outright or doesn’t reach for at all. There’s no third option left where it pauses and waits; you removed that option the moment you removed the person on the other end of it. Reserve danger-full-access and --yolo for throwaway containers - they have no business on a runner holding your repo and your API key.
Untrusted input, not just untrusted commands
Section titled “Untrusted input, not just untrusted commands”One CI-specific hazard: the data this job reads - 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 is exactly what an unattended agent shouldn’t be free to act on. Two habits, both already yours: lean on the sandbox for the hard limits rather than the prompt (network off means it can’t exfiltrate anything even if talked into trying), and hand it the specific transactions to check instead of turning it loose to hunt for them, the way the last lesson piped a CSV straight in.
What the exit code is actually worth
Section titled “What the exit code is actually worth”The workflow above runs, exits, and reports done. The honest question - the one an unattended check exists to answer - is what “done” got past before it said so:
That’s the same nine-chore overnight batch, replayed as a report you wake up to instead of a diff you watch: nine claims of “done,” and the only witness so far is the worker that made them. Ship the report unread and two of the nine were genuinely true - the other seven surface later, on their own schedule, as a broken build, a silently wrong total, a notification nobody sent. Wire in the build and the type check and you catch the loudest failures, the ones a compiler can name - but the logic errors and the seam breaks still ship clean, because nothing tested whether the code was correct, only whether it was valid. Wire in the full stack - build, types, the suite, an end-to-end pass - and the report earns almost every checkmark on it: retried against a named error, or stopped and flagged with a trace when it genuinely can’t self-correct. One row still ships wrong regardless of which stack you wire in: a ticket read one plausible way instead of the way its author meant. A check can only catch a claim somebody made falsifiable - it has no way to catch a requirement nobody wrote down.
The exit code is your review now, and it’s worth precisely as much as the checks somebody wired into the run - no more; that’s the exact shape of this chapter’s contract, back where you can see it. The pytest step in the workflow above is one such check. The categoriser’s own correctness against budgetcli’s money rule is not, yet, unless you’ve written a test that pins it down.
The transaction check now authenticates itself, runs inside a posture granted before it existed, and reports through checks you chose on purpose. But look at what you didn’t pin: this same job still loads your personal config and machine’s command policy, which means it can behave differently on the runner than it did on your laptop, and differently again next month once either has drifted. Closing that gap is the next lesson.