Add a release gate to Claude Code: stop shipping half-finished AI code
Build a Claude Code release gate: a shipper subagent that runs your real tests, typecheck, lint and build, then returns a SHIP or BLOCK verdict.
9 min read
A release gate in Claude Code is a dedicated subagent whose only job is to run your project's real checks (tests, typecheck, lint, build) and return a plain verdict: SHIP or BLOCK. It never writes code. It just tells you, with evidence, whether the work is actually finished.
You need one because "done" from a coding agent means "I stopped typing." It doesn't mean the tests pass. This post walks through the pattern, a complete .claude/agents/shipper.md file, a /ship slash command that drives it, and a non-AI backstop for when you forget to run it.
The problem: "done" isn't done
If you use Claude Code or Codex daily, you've seen this. The agent finishes a feature, writes a tidy summary, and says it's ready. Then:
- The tests were never run, or were run once before the last three edits.
tscfails on a type the agent "fixed" by changing a signature in one place.- There's a
// TODO: handle error casesitting in the new handler. - A
console.log(user)made it into the diff. npm run buildbreaks because of an import that only resolves in dev.
None of this is the model being bad at code. It's a process gap: the agent that wrote the code is also deciding whether it's finished. The fix is to separate building from judging.
The shipper pattern
The loop:
- An implementer (the main thread, or a specialist subagent) writes the code.
- A separate
shippersubagent runs the project's actual check commands. - The shipper reports each command, its result, a list of blockers, and a single verdict.
- On BLOCK, the blockers go back to the implementer. Fix, rerun, repeat.
The shipper has three hard rules: it never fixes code, it never skips a check because it "looks fine," and if a check can't run, that's a BLOCK, not a pass.
Why it has to be a separate agent
Fresh context. Each subagent runs in its own context window. The shipper doesn't see the 40 turns where the implementer convinced itself the edge case was handled. It sees the repo and the commands.
No sunk-cost bias. An agent asked "is your code ready?" tends to say yes. An agent whose entire system prompt is "find reasons this can't ship, change nothing" behaves differently. Dropping its edit tools removes the easy path to "just fixing" things.
If you're new to how subagents are defined and invoked, the Claude Code subagents guide covers the file format and delegation in detail.
Why the main thread orchestrates
Subagents can't spawn other subagents. So the shipper can't call the implementer back when it finds a problem, and an implementer subagent can't call the shipper when it's finished. The loop has to live in the main conversation: implement, call shipper, read verdict, hand blockers back, call shipper again.
That's exactly what a slash command is good at, since it runs in the main thread.
Which checks to run
The core four, in order:
| Check | Why | Typical command |
|---|---|---|
| Tests | Behavior still works | npm test, pytest, go test ./... |
| Typecheck | Catches broken signatures across files | tsc --noEmit, mypy ., pyright |
| Lint | Catches unused vars, bad patterns | eslint ., ruff check . |
| Build | Proves it actually compiles/bundles | npm run build, cargo build |
Optional checks that catch the classic AI leftovers, run only against the diff so you don't fail on old code:
- Leftover markers:
TODO,FIXME,console.log,debugger,print(in added lines. - Secrets: anything that looks like an API key, private key, or
.envfile in the diff. - Migrations: if schema/model files changed, a migration file should exist too.
Discovering the right commands
Don't hard-code npm test into the agent. Have it read the project and pick the real commands:
package.json→scripts(test,typecheck,lint,build). Check the lockfile to picknpm,pnpm,yarnorbun.Makefile→ targets liketest,lint,check,build.pyproject.toml→ tool sections ([tool.pytest],[tool.ruff],[tool.mypy]) and any task runner config.Cargo.toml,go.mod→ the standard toolchain commands.CLAUDE.md→ if you've documented "runmake checkbefore committing," that wins.
If a check doesn't exist for the project (say, no typecheck in a plain JS repo), the shipper should say so as N/A with the reason. That's different from a check that exists but fails to run, which is a BLOCK.
The SHIP/BLOCK output format
A fixed format matters. You want to scan it in two seconds, and you want the main thread to be able to pass the blockers list straight back to the implementer. Here's what a BLOCK looks like:
SHIP CHECK
Checks
- tests pnpm test FAIL 2 failed, 118 passed
- typecheck pnpm tsc --noEmit FAIL src/api/orders.ts:42 TS2345
- lint pnpm lint PASS
- build pnpm build PASS
- diff scan git diff main...HEAD FAIL 1 TODO, 1 console.log
- secrets git diff main...HEAD PASS
- migrations schema unchanged N/A
Blockers
1. tests: orders.test.ts > "rejects empty cart" expected 400, got 500
2. tests: orders.test.ts > "applies discount" TypeError: total is undefined
3. typecheck: src/api/orders.ts:42 Argument of type 'string' is not assignable to parameter of type 'number'
4. diff: src/api/orders.ts:57 "// TODO: validate coupon"
5. diff: src/api/orders.ts:63 console.log(cart)
Verdict: BLOCKAnd a pass is just as explicit:
Verdict: SHIP
All 4 core checks passed. Diff scan clean. No migrations required.No "mostly ready." No "should be fine once you fix the test." Two states.
The complete shipper agent
Save this as .claude/agents/shipper.md in your project (or ~/.claude/agents/shipper.md to use it everywhere):
---
name: shipper
description: Release gate. Use before any release, merge, PR, or when the user says a task is done. Runs the project's real tests, typecheck, lint and build, scans the diff for leftovers and secrets, and returns a SHIP or BLOCK verdict. Never edits code.
tools: Bash, Read, Grep, Glob
model: inherit
---
You are the shipper: a release gate. Your only job is to decide whether the
current changes can ship, using the project's real checks. You report. You
never fix.
## Steps
1. Discover commands. Read package.json scripts (and the lockfile to choose
npm/pnpm/yarn/bun), Makefile, pyproject.toml, Cargo.toml, go.mod, and
CLAUDE.md. Prefer commands the project documents over generic defaults.
2. Find the diff. Use `git diff main...HEAD` plus `git diff` for uncommitted
changes. If the base branch isn't main, detect it from the git remote.
3. Run, in order: tests, typecheck, lint, build. Run each one even if an
earlier one fails. Capture exit code and the key lines of output.
4. Scan added lines in the diff for: TODO, FIXME, console.log, debugger,
stray print( calls, hard-coded secrets (API keys, private keys, tokens),
and committed .env files.
5. If schema or model files changed, confirm a matching migration exists.
6. Produce the report in the exact format below.
## Rules
- Never edit, create, or delete files. Never run formatters with --fix or
any command that writes to the repo.
- Never skip a check because the code "looks fine".
- If a check exists but cannot run (missing dependency, crash, timeout),
that is a BLOCK. Report the error.
- If a check does not exist for this project, mark it N/A with the reason.
- Report failures precisely: file, line, test name, error message.
- Do not suggest fixes beyond one short line per blocker.
- Verdict is SHIP only if every applicable check passed and the diff scan
is clean.
## Output format
SHIP CHECK
Checks
- <check> <exact command> PASS|FAIL|N/A <one-line result>
Blockers
1. <check>: <file:line or test name> <error>
(or "None")
Verdict: SHIP | BLOCKA few notes on the choices:
tools: Bash, Read, Grep, Globgives it what it needs to run commands and inspect files, and noEditorWrite. Bash can technically still write files, which is why the rules say so explicitly.model: inherituses whatever model your session is on, so you're not pinned to one.- The description says "use before any release, merge, PR, or when the user says a task is done." Claude uses the description to decide when to delegate, so that wording encourages it to call the shipper on its own.
You can always invoke it directly: "use the shipper agent to check this branch."
A /ship slash command to drive the loop
Because the main thread has to orchestrate, put the loop in a slash command. Save as .claude/commands/ship.md:
---
description: Run the release gate and loop until SHIP or a real blocker
argument-hint: [base-branch]
---
Run the release gate on the current changes. Base branch: $ARGUMENTS
(default to main if empty).
1. Use the shipper agent to check the current changes against the base
branch. Show me its full report.
2. If the verdict is SHIP, stop and summarize what passed.
3. If the verdict is BLOCK:
- Hand the numbered blockers list back to whoever implemented the change
(you, or the specialist agent that wrote it). Fix only those blockers.
Do not touch unrelated code. Do not delete or skip failing tests to
make them pass.
- Run the shipper agent again.
4. Repeat at most 3 rounds. If it is still BLOCK after 3, stop and show me
the remaining blockers with your best explanation of each.Now /ship or /ship develop runs the whole loop. The cap on rounds matters: without it, an agent fighting a flaky test can burn a lot of turns. The "don't delete or skip failing tests" line matters even more, since that's an easy way for an implementer to "fix" a red gate.
The slash command guide covers $ARGUMENTS, argument-hint, and the other frontmatter options.
What to do on BLOCK
Treat the blockers list as the spec for the next round. It's numbered, specific, and scoped, which beats "tests are failing, fix it" because the shipper already did the triage.
If the same blocker survives two rounds, step in yourself. It usually means the test is right and the approach is wrong, or the test is flaky and needs a human decision.
The non-AI backstop: pre-push hook and CI
An agent-based gate only runs when someone remembers to run it. Back it up with something that doesn't depend on a model at all.
A git pre-push hook at .git/hooks/pre-push (make it executable with chmod +x):
#!/bin/sh
set -e
npm test
npx tsc --noEmit
npm run lint
npm run buildAnd the same checks in CI, for example a GitHub Actions workflow:
name: checks
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
- run: npx tsc --noEmit
- run: npm run lint
- run: npm run buildSwap in your project's real commands. The shipper and CI should run the same checks; the shipper just runs them earlier, explains failures in context, and adds the diff scan that CI usually doesn't do.
Where this fits in a bigger workflow
The gate works best as the last step of a pipeline: a planner breaks down the feature, specialists implement pieces, and the shipper decides. SlashKits ships it that way, with a shipper agent, a /ship command, and a /build-feature command that walks a tech-lead's plan through the specialists and ends at the gate. But the two files above are the whole idea.
Start with the agent file, run it once on your current branch, and see what it finds.