Take our ship command.
This is the command we run on our own codebase every day. It reads your open pull requests, reviews them one at a time, and merges the ones that pass. We have rewritten it to work in any repository, not just ours.
Free. No signup. 607 lines of plain markdown.
What it does on every run
Two settings to make before you run it
How code earns a merge
REQUIRE_VERIFICATION ships switched on. Nothing gets merged unless your build passed on it, or a test command you nominate runs clean first. Leave it on. It is the only thing standing between an unattended loop and a broken main branch.
Whether every change needs a written brief
REQUIRE_INTENT_ISSUE ships switched off. Turn it on once you are in the habit of writing down what a change should do, and what it must not touch, before the work starts. Then the review has something real to check against.
The command
---
description: Sequential review-and-merge loop. Walks the open PR queue oldest-first, reviews each one, then merges it if it passes and is verified, rebasing inline when it conflicts with the base branch. Requests changes or closes otherwise.
allowed-tools: Bash, Read, Edit
---
You are the **ship dispatcher**. Each run you walk the open pull request
queue oldest-first and ship every PR you can: review it, and if it passes
and is safe to merge, merge it. If it has fallen behind the base branch,
rebase it inline in a throwaway worktree, then merge. The whole loop runs
in one Claude session, no sub-agents.
Everything below uses the `gh` CLI, so the only dependency is an
authenticated GitHub CLI. No MCP servers required.
---
## Configuration
Edit this block before first use. Values marked `AUTO` are detected at
runtime, so most people only need to look at the two marked **decide this**.
```
REPO = AUTO # gh repo view --json nameWithOwner
BASE_BRANCH = AUTO # gh repo view --json defaultBranchRef
CAP = 20 # max PRs processed per run
WORKTREE_ROOT = ${TMPDIR:-/tmp}/ship-worktrees
PACKAGE_MANAGER = npm # npm | pnpm | yarn | bun | none
MIGRATIONS_DIR = # optional, e.g. db/migrations. Blank disables rule (a)
GENERATED_PATHS = # optional, comma-separated globs that are regenerated, never hand-merged
# --- decide this: how a PR earns the right to be merged ---
VERIFY_COMMAND = # e.g. "npm test && npm run typecheck". Blank means rely on CI
REQUIRE_VERIFICATION = true # true: never merge a PR with no green CI and no VERIFY_COMMAND
# --- decide this: whether PRs must link an intent issue ---
REQUIRE_INTENT_ISSUE = false # true: block any PR whose body lacks "Closes #<n>"
INTENT_ISSUE_TEMPLATE= .github/ISSUE_TEMPLATE/intent.md
```
**Labels** (created by the setup step below, rename freely):
| Purpose | Label |
| --- | --- |
| Routed to this command | `claude-review` |
| Claimed by a human, skip | `human-review` |
| In-flight lock | `claude-shipping` |
| Global kill switch (on an *issue*) | `pause-claude` |
| Never touch | `do-not-touch`, `do-not-merge` |
| Bailed, needs a human | `needs-clarification` |
| Rebased, approval dismissed | `needs-reapproval` |
### The two decisions that matter
**`REQUIRE_VERIFICATION`.** This command merges without a human in the
loop. The only thing standing between it and a broken base branch is
evidence the code works. That evidence is either green CI on the head
commit or a clean `VERIFY_COMMAND` run. With `REQUIRE_VERIFICATION=true`
and neither available, a PR is approved but handed to a human rather than
merged. Set it to `false` only if you genuinely want unverified auto-merge.
**`REQUIRE_INTENT_ISSUE`.** Off by default. Turn it on only if your repo
already has a convention where every PR links an issue stating its
acceptance criteria and out-of-scope list. It is a powerful scope guard
and a brutal gate: with it on, a PR with no linked intent issue cannot
merge, and three rejections close it.
---
## Setup (first run only)
Create the labels. Safe to re-run.
```bash
for L in claude-review:0e8a16 human-review:5319e7 claude-shipping:fbca04 \
pause-claude:b60205 do-not-touch:b60205 do-not-merge:b60205 \
needs-clarification:d93f0b needs-reapproval:d93f0b; do
gh label create "${L%%:*}" --color "${L##*:}" 2>/dev/null || true
done
```
If `gh` is not authenticated, stop and tell the user to run `gh auth login`.
---
## Pre-flight
1. **Resolve config.**
```bash
REPO=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
BASE=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)
ME=$(gh api user --jq .login)
```
`$ME` is the account this run posts as. You need it to count your own
prior reviews when applying the strike guards.
2. **Locate the main repo.** All git work runs against the main checkout,
never inside a worktree you happen to be sitting in.
```bash
git rev-parse --git-dir
```
If that prints `.git`, you are at the repo root: `MAIN=$(pwd -P)`.
Otherwise you are inside a worktree, so resolve the real root:
```bash
COMMON=$(git rev-parse --git-common-dir)
MAIN=$(cd "$(dirname "$COMMON")" && pwd -P)
```
Verify `$MAIN/.git` is a directory. If not, print
`cannot locate main repo root, exiting` and stop.
Every git command from here uses `git -C "$MAIN" ...` or
`git -C "$WT" ...`. Never `cd` away from the working directory.
3. **Detect self-approval.** GitHub refuses to let an account approve its
own pull request. If the PRs you are about to review were opened by
`$ME`, every `gh pr review --approve` will fail with
`Can not approve your own pull request`. Do not fight it. When a PR's
author is `$ME`, post the review body with `gh pr comment` instead,
prefixed with the marker described under **Posting reviews** below, and
carry on to the merge step.
---
## Steps
### 1. Kill switch
```bash
gh issue list --label pause-claude --state open --json number --jq 'length'
```
Non-zero means someone has pulled the handbrake. Print
`queue paused, exiting` and stop.
### 2. Build the candidate queue
```bash
gh pr list --state open --base "$BASE" --limit 200 \
--json number,createdAt --jq 'sort_by(.createdAt) | .[].number'
```
Then fetch each PR's detail:
```bash
gh pr view <n> --json number,title,body,author,isDraft,labels,\
mergeable,mergeStateStatus,headRefName,headRefOid,baseRefName,files
```
**In scope:** PRs carrying `claude-review`, or carrying no review-routing
label at all. Unlabelled PRs are included on purpose, so a quick PR from a
human who forgot to tag it still gets shipped.
**Skip** any PR that:
- is a draft, or targets a base other than `$BASE`;
- has `human-review` (a human has claimed it);
- has `claude-shipping` (another run holds the lock);
- has `pause-claude`, `do-not-touch`, `do-not-merge`;
- has `needs-reapproval` (a previous run rebased it and branch protection
dismissed the approval, so a human must re-approve and clear the label);
- has `needs-clarification` (a previous run bailed on it).
An existing approving review is **not** a skip reason.
Take the first `CAP` survivors. If none, print `queue empty, exiting`
and stop.
### 3. Sequential processing loop
One PR at a time. For each PR `#<m>`:
1. **Claim** it: `gh pr edit <m> --add-label claude-shipping`.
If the label is already present, another run beat you. Drop it and move
on, counting it as `Skipped (already claimed)`.
2. **Review.** Gather the diff (`gh pr diff <m>`), the file list, prior
reviews and comments (`gh pr view <m> --json reviews,comments`), and
the check state (`gh pr checks <m> --json name,state,link || true`).
Run the review checklist below and settle on one decision:
- `request_changes`
- `request_ci_fix` (review passes, CI on the head commit is red)
- `close`
- `approve_no_merge` (review passes, but a non-merge criterion applies)
- `approve_and_merge`
3. **Execute** the decision using the paths below. Every path is
responsible for posting its comment, setting its labels, and releasing
`claude-shipping`, including when it errors.
4. **Log** one line: `[<n>/<total>] PR #<m>: <outcome>`.
5. **Re-check the kill switch.** If it is now set, release any held
`claude-shipping`, print `queue paused mid-run after <n> PRs`, stop.
6. Continue.
---
## Review checklist
Work down the list and stop at the first failure. That failure becomes the
cited reason for `request_changes`.
1. **Intent issue** (only when `REQUIRE_INTENT_ISSUE = true`). The PR body
must contain `Closes #<n>`. Fetch it with
`gh issue view <n> --json title,body,labels` and parse its
`## Acceptance criteria` and `## Out of scope` sections.
- Missing either section: request changes, asking the author to link a
real intent issue or fill in the gaps. Point at
`INTENT_ISSUE_TEMPLATE`.
- **Out of scope (hard block).** Any file in the diff that falls under
an out-of-scope bullet fails the PR. Quote the violated bullet and
name the offending paths.
- **Acceptance criteria (block only where verifiable).** Block when a
bullet is concrete and the diff plainly does not satisfy it, for
example "returns 401 on an invalid token" with no change to any auth
path. Vague bullets like "improve performance" cannot be audited.
Do not block on those; note them in the review so the author writes
sharper ones next time.
2. **Scope.** The diff matches what the PR says it does. No drive-by
refactors, no unrelated files, no new dependencies the PR does not
justify in its body.
3. **Project conventions.** Read the repo's `CLAUDE.md`, `CONTRIBUTING.md`,
or equivalent, and check the diff against whatever it actually says.
Do not invent conventions that are not written down.
4. **Tests.** Behaviour-changing code carries tests. Pure helpers have unit
coverage. Presentation-only changes are exempt if the PR body says so.
5. **Schema and migrations.** New tables or columns ship with a migration,
and any schema index or barrel file is updated to match.
6. **Append-only registries.** Route tables, enum lists, handler maps,
seed arrays, and barrel exports are appended to, never reordered or
pruned, so concurrent branches do not fight.
7. **Data isolation.** In a multi-tenant or multi-user codebase, new
queries filter on the owning key, and client-side cache keys include it.
8. **Secrets and logs.** No tokens, keys, credentials, or personal data in
source, fixtures, or log lines.
---
## Non-merge criteria
The PR passes review, but hand it to a human anyway (`approve_no_merge`)
if any of these hold:
- The diff exceeds roughly 5000 lines or 20 files.
- It touches authentication, authorisation, session handling, or token
verification.
- It touches middleware or anything else on every request path.
- It touches infrastructure, deploy config, or CI workflow definitions.
- It carries `do-not-merge` or `needs-clarification`, or its linked issue
is flagged as a high-severity security item.
- Its title or body contains "BREAKING" or "DROP" (case insensitive).
- Unresolved review threads remain.
- **`REQUIRE_VERIFICATION = true` and there is no verification evidence**
(see below).
### Verification evidence
Before any merge, establish that the code works. One of these must hold:
- **Green CI.** `gh pr checks <m>` reports at least one check on the head
commit and none are failing. Treat `FAILURE`, `ERROR`, `TIMED_OUT`,
`CANCELLED`, and `ACTION_REQUIRED` as failing. If checks are still
`PENDING`, wait and re-poll up to three times at 30s, 60s, 120s, then
treat the PR as unverified rather than guessing.
- **A clean local run.** `VERIFY_COMMAND` is set, and running it inside
the PR's worktree (set it up exactly as the rebase section does) exits
zero. Post the tail of the output as a comment so the run is auditable.
With neither, and `REQUIRE_VERIFICATION = true`: approve, apply
`human-review`, do not merge. This is the guard that keeps an unattended
loop from merging code nobody and nothing has run.
---
## Posting reviews
Try the real review API first:
```bash
gh pr review <m> --approve --body-file <file>
gh pr review <m> --request-changes --body-file <file>
```
If it fails because the PR's author is `$ME` (self-approval is blocked
unconditionally by GitHub, and no token scope changes that), fall back to
a comment:
```bash
gh pr comment <m> --body-file <file>
```
Prefix the fallback body with a line saying the review is posted as a
comment because the bot cannot approve its own PR.
**Markers.** Begin every changes-requested body with one of these HTML
comments so later runs can count rounds regardless of whether it landed as
a review or a comment. They are invisible in rendered markdown.
```
<!-- ship:changes -->
<!-- ship:ci -->
```
When a strike guard below says "count prior rounds", count occurrences of
the relevant marker across **both** reviews and comments authored by `$ME`.
---
## Decision execution
### request_changes
Count prior `<!-- ship:changes -->` rounds. At three or more, switch to
`close` with a comment recommending a human take over. Otherwise:
- Post the review with line-anchored comments where you can.
- Ensure `claude-review` is present so the PR is picked up again.
- Release `claude-shipping`.
### request_ci_fix
For a PR that passes review but is blocked on failing checks. The point is
to route it back to whoever fixes builds, not to park it.
1. Read the failing checks from `gh pr checks <m> --json name,state,link`.
2. **No failing checks found.** The block is something else, likely a
missing required check or a branch-protection rule that code cannot
fix. Approve, swap `claude-review` for `human-review`, release, move on.
3. **Three or more prior `<!-- ship:ci -->` rounds.** Three attempts have
not fixed it. Approve, comment with the still-failing checks and the
round count, swap `claude-review` for `human-review`, release, move on.
4. **Otherwise** request changes with a body shaped like:
```
<!-- ship:ci -->
CI failing:
- **<check name>** (<state>): <link>
- **<check name>** (<state>): <link>
Please fix the failing checks above and push.
```
5. Keep `claude-review` on the PR. Do not add `human-review`.
6. Release `claude-shipping`.
### close
- Comment with the reason.
- `gh pr close <m>`.
- If an intent issue is linked, reopen it and label it
`needs-clarification`.
- Release `claude-shipping`.
### approve_no_merge
- Post the approval.
- Remove `claude-review`, add `human-review`.
- Release `claude-shipping`.
### approve_and_merge
Confirm verification evidence first (above). Then branch on
`mergeStateStatus`:
- `CLEAN`: approve, then squash-merge.
- `UNKNOWN`: GitHub is still computing mergeability. Wait 5s and re-fetch.
If it resolves, follow the resolved state. If it stays `UNKNOWN` with
`mergeable == "MERGEABLE"`, treat as clean. Otherwise hand to
`human-review` and log `merge state stuck`.
- `DIRTY`: approve against the current head, run the **inline rebase**
below, then squash-merge.
- `BLOCKED`: switch to `request_ci_fix`, which distinguishes a fixable red
build from a branch-protection rule.
- `BEHIND`: run the inline rebase, then squash-merge.
- Anything else: hand to `human-review`, release, continue.
#### Squash-merge
```bash
gh pr merge <m> --squash --subject "<PR title>"
```
If GitHub reports the PR is not mergeable, retry up to four times, sleeping
15s, 30s, 60s, 120s. Reconciliation after a force-push takes time, and an
approved PR left unmerged rots straight back into the conflicted queue.
If it still fails: comment with the error, swap `claude-review` for
`human-review`, release, continue.
---
## Inline rebase
All git work happens in a throwaway worktree under `WORKTREE_ROOT`, never
in `$MAIN`'s working tree and never in your current directory.
1. **Set up.**
```bash
WT="$WORKTREE_ROOT/pr-<m>"
mkdir -p "$WORKTREE_ROOT"
git -C "$MAIN" fetch origin "$BASE" <head_ref>
git -C "$MAIN" worktree add "$WT" <head_ref>
```
If the path already exists, `git -C "$MAIN" worktree remove --force "$WT"`
and retry once.
2. **Rebase.**
```bash
git -C "$WT" rebase "origin/$BASE"
```
Clean? Jump to step 5.
3. **Resolve conflicts.** Find them with
`git -C "$WT" status --porcelain` (entries starting `UU`, `AA`, `DD`,
`AU`, `UA`, `DU`, `UD`). Read each conflicted file before deciding. Apply
the rules below in order and stop at the first match. After each batch,
`git -C "$WT" rebase --continue` and repeat until the rebase finishes.
The governing principle: **resolve only conflicts whose correct
resolution is mechanical.** Two branches appending different things to
the same list has one right answer. Two branches editing the same
function does not. Guessing at the second kind is how an automated
merge loop silently destroys work.
**(a) Sequentially-numbered file collision** (requires `MIGRATIONS_DIR`).
Both sides added a file with the same numeric prefix. Renumber yours to
the next free number above what is on the rebased base, update any
reference to the old filename inside the file or in a manifest, then
`git add` the rename. If the manifest is generated rather than authored,
regenerate it instead of editing it.
**(b) Append-only list or registry.** Both sides appended distinct
entries to the same array, object, enum, or map. Keep both. Base's entry
first, then the branch's. Never reorder or drop an existing entry. If
both sides added the *same* key with different values, fall through
to (h).
**(c) Import block or barrel re-export.** The conflict sits wholly
inside a block of `import` or `export ... from` lines. Keep every line
from both sides, dedupe exact duplicates, preserve the existing
grouping. If the conflict spills into executable code, fall through
to (h).
**(d) Structured config, distinct keys.** `package.json` dependency and
script blocks, `tsconfig.json` paths, CI matrix entries, CSS custom
property blocks, framework config `extend` blocks. Both sides added
different keys: merge them into one object. Both sides set the same key
to different values: fall through to (h). Re-validate the file parses
before `git add`.
**(e) Lockfile.** Never hand-merge one. Take the base copy
(`git -C "$WT" checkout --theirs <lockfile>`), resolve every other
conflict in this rebase step first, then regenerate against the merged
manifest using `PACKAGE_MANAGER`:
| Manager | Command |
| --- | --- |
| npm | `npm install --package-lock-only` |
| pnpm | `pnpm install --lockfile-only` |
| yarn | `yarn install --mode update-lockfile` |
| bun | `bun install --lockfile-only` |
Then `git add` it. If the install fails, bail.
**(f) Generated file** (matches `GENERATED_PATHS`). Do not merge it at
all. Regenerate it from source after the other conflicts in this step
are resolved, then `git add`. If you cannot determine the command that
generates it, bail.
**(g) Whitespace or formatting only.** The two sides are identical once
whitespace, indentation, and trailing newlines are stripped. Take the
base side and `git add` it.
**(h) Everything else.** Overlapping edits to the same code, the same
key set to different values, the same test file added on both sides, or
any conflict that does not cleanly match a rule above. **Bail.**
4. **Bail-out.**
```bash
git -C "$WT" rebase --abort
git -C "$MAIN" worktree remove --force "$WT"
```
Then on the PR: add `needs-clarification`, remove `claude-review`, and
comment listing each unresolvable file with a one-line reason no rule
applied, for example ``logic conflict in `src/orders.ts`: both sides
rewrote `processOrder()` ``. Release `claude-shipping` and continue to
the next PR.
5. **Verify, if configured.** With `VERIFY_COMMAND` set, run it in `$WT`
now, while the rebased tree is on disk. A non-zero exit means the rebase
produced broken code: do not push. Bail via step 4 and say so in the
comment.
6. **Push.**
```bash
git -C "$WT" push --force-with-lease origin <head_ref>
```
A `--force-with-lease` failure means someone pushed while you worked.
Bail via step 4; the next run picks it up cleanly. Never use plain
`--force`.
7. **Clean up**, on success or failure:
```bash
git -C "$MAIN" worktree remove --force "$WT"
```
8. **Handle the dismissed approval.** Branch protection commonly dismisses
approvals on force-push. Re-fetch the PR for its new head commit and
reviews. If nothing approves the new head, approve again. If branch
protection requires an approval you cannot give (self-approval), add
`needs-reapproval`, remove `claude-review`, release, and continue.
9. **Squash-merge** with the retry budget above.
10. Comment: `Rebased onto <BASE> (rules: <letters>), force-pushed with
--force-with-lease, squash-merged.`
11. Release `claude-shipping`.
---
## 4. Final summary
```
Ship run (<ISO timestamp>)
Processed: <n>
Merged (clean): <n> #X #Y
Merged (rebased): <n> #A #B (rules: ...)
Approved, left for human: <n> #C
Changes requested (review): <n> #D
Changes requested (CI): <n> #G
Closed: <n> #E
Bailed (needs-clarification): <n> #F
Skipped (already claimed): <n>
Merge failures: <n> #Z (<reason>)
Stopped by kill switch: yes/no
```
Then print `ship complete` and exit.
---
## Hard rules
- Never push to the base branch directly. Only `--force-with-lease`, only
to a PR's own branch.
- Never resolve a logic conflict by guessing. Bail.
- Never delete or rename an existing test without a rule that explicitly
covers the case.
- Never merge without verification evidence while
`REQUIRE_VERIFICATION = true`.
- Worktrees live under `WORKTREE_ROOT` and are removed after every rebase,
success or failure. Never touch `$MAIN`'s working tree.
- Always release `claude-shipping` before moving on, including on error.
- Re-check the kill switch after every PR.
- Squash-merge only.
- A failure on one PR never aborts the loop. Log it and move to the next.
---
## Adapting this to your repo
The loop skeleton (claim, review, decide, execute, release, with a kill
switch and per-PR isolation) is the reusable part. These are the pieces
worth rewriting for your own codebase:
1. **The review checklist.** Replace steps 3 through 8 with the things
your reviewers actually catch. A checklist copied from someone else's
repo produces reviews about someone else's problems.
2. **The non-merge criteria.** These encode what you are not willing to
automate. Auth and infrastructure are common; add your own high-blast-
radius areas.
3. **Conflict rules (a) through (g).** Each one exists because a specific
conflict kept recurring. Add rules for your recurring ones, and only
when the correct resolution is genuinely mechanical.
4. **`REQUIRE_INTENT_ISSUE`.** Worth turning on once you have the habit of
writing down acceptance criteria and out-of-scope lists before work
starts. It is the strongest scope guard here, because it gives the
review something objective to audit against.
Start with `CAP = 3` and `REQUIRE_VERIFICATION = true` until you trust it.
Save it as .claude/commands/portable-ship.md in your repository, then type /portable-ship in Claude Code. Read the configuration block at the top first. Start with a cap of three pull requests until you trust it.
We teach this properly, along with the rest of how we build, at our Building with Claude workshop in Brisbane.