Releasing · page 1 of 4
The ship steps in order, one release at a time, and what each exit code means.
When a story has passed its tests and reviews and its change is committed, the story session runs foreman ship --story <id> on the story branch. This page is for anyone who wants to know what happens between that command and the merge: the steps, the order, the lock that keeps releases apart, and the exit codes the session acts on. The GitHub deployment, the Supabase steps and the checks after a deploy have pages of their own: GitHub deployments and Supabase and Release safety.
The steps
ship.steps in foreman.config.json lists the steps by name, and foreman ship runs them in that order, stopping at the first one that is not right. With no ship block the list is:
"ship": {
"steps": ["merge-main", "resources", "push", "pr", "ci", "mergeable-recheck", "merge"],
"ci": { "required": [] }
}
| Name | What it does | Code on failure |
|---|---|---|
merge-main |
Brings the base branch into the story branch, so the release tests the branch on top of the newest base. A branch that already contains the base runs nothing. A conflict in a file listed under generated is settled by running that entry’s regenerate command and committing. A conflict in anything else aborts the merge; the result’s conflicts lists the files. When the merge brought in new commits and no later step tests the merged code (no ci step), it runs the project’s checks on the merged tree: those whose when is always or matches a file changed since the branch forked, with install.run first when a changed file matches install.when. Each command runs with sh -c for at most 20 minutes. A failure lists each as { run, exit, timedOut, tail }, the tail being the last 4,000 characters with secrets blanked, and the merge stays on the branch so the fix starts from the merged code. A check foreman checks already ran in the session on this exact merged tree is not run again; its record counts, and mergeChecksReused names it. |
6 for a conflict in code, 2 for a failed check, 1 when a regenerate command fails |
resources |
One story at a time may change an exclusive resource (config resources, such as the database schema). Normally the slot took the resource when it picked the story. A branch that grew changes to one without it takes it now if it is free, and stops when another live slot holds it. A lock left by a dead process is broken and taken. |
7 |
push |
git push -u <remote> <branch>. Git’s own message is left out of the result, since it can show the remote address with credentials in it. |
1 |
pr |
Finds the branch’s pull request or opens one with gh pr create --fill, into ship.pr.into when set, else the base branch. Then it asks GitHub whether the pull request can merge, up to 18 times 10 seconds apart; one that cannot merge gets no CI run, so the release stops here rather than wait for one. Once the pull request is open it raises the review event on the story source (a Jira source comments and moves the issue), with the pull request address and the short commit, once per release run. |
1 |
ci |
Waits for the pull request’s checks and requires them to pass. See CI: required checks and timeouts. | 2 |
mergeable-recheck |
Just before the first point of no return, checks again that the base branch is still an ancestor of this branch and that GitHub still reports the pull request MERGEABLE (an UNKNOWN answer is polled 6 times, 5 seconds apart). See When the base moved. It needs the prNumber fact from pr, so it goes after that step. |
1 |
merge |
Merges the pull request and tidies up. See The merge. | 1 |
Four more core steps can go in the list: prepare-migrations, supabase-credentials and migrate-supabase for a hosted Supabase database, and deploy-github for a host that reports deployments to GitHub, with smoke and watch after it. rollback is never a step: naming it in ship.steps is a config error, since it runs only on failure, from ship.rollback.command. A name the registry does not know stops the release at the start with exit 1. With "pack": "salesforce", the pack’s own steps join the table (see Salesforce ship steps); a pack step may not take a core step’s name.
Two switches drop steps without editing the list. features.mergeableRecheck off leaves out mergeable-recheck; features.watchRollback off leaves out watch and turns off the rollback. A config with no features block counts as every switch on. The config is read fresh for every release, with the local settings layer on top, so a change to a timeout or a switch applies to the next release (see Settings).
Each step runs with the project’s environment file loaded. foreman ship reads Foreman’s own keys from .env.foreman (only the names the steps declare they need) and then the project’s envFile; neither is ever printed. foreman ship --preflight says what each step needs and changes nothing: it prints one JSON line with gh_signed_in, each step’s needs (a configured rollback is listed last with "when": "on failure"), missing_env (names only) and warnings. The warnings cover a switched-off step still in the list, an empty ship.ci.required, migrate-supabase without projectRef or without supabase-credentials before push, smoke with no URLs, watch without a rollback, a rollback with no deploy step, and a point of no return with no mergeable-recheck right before it.
One release at a time
Several slots build at once, but releases go one at a time. A slot is one of the worktrees Foreman builds in; the run picks a story into each. From its first step to its last, foreman ship holds the ship lock, so CI always tests a branch on top of the newest base. A second release waits, polling every 20 seconds, and the command centre shows that slot as “waiting for the ship lock”. A lock left by a release process that died is broken, with a warning in the event feed naming the story and the step it was at. A halt that appears while a release is waiting ends the wait with exit 8.
The name of each step is written on the lock before it runs. If the whole run dies in the middle of a release, the next start reads it: a release cut off at merge or migrate-supabase (or the older stage name hosted-db) is finished, never redone, because the live database or the base branch may already have changed. While the release runs, Foreman also beats once a minute so the quiet watchdog never mistakes a long step for a stuck slot.
Before any step runs, foreman ship checks the branch. It exits 1 at the stage start when the checkout is not on a branchPrefix branch (story/ by default), when the working tree has uncommitted changes, when a merge is in progress (MERGE_HEAD is set), when fetching the base branch fails three times, or when the branch has no commits ahead of the base. When the HALT file exists it exits 8 before anything else.
Pull request into another branch
Stories are always cut from baseBranch, but the pull request can land somewhere else. ship.pr.into names the branch the pull request merges into (cut from main, pull request into qa). It must be a plain branch name, and with the Salesforce pack it may not be the pack’s productionBranch: nothing merges into production. ship.pr.mergeMethod is merge, squash or rebase, merge by default. ship.merge.keepBranch: true makes the merge leave the story branch in place, remote and local; the default deletes both. These three are also settings on the dashboard (Settings), applied to the next story.
"ship": {
"steps": ["merge-main", "resources", "push", "pr", "ci", "mergeable-recheck", "merge"],
"pr": { "into": "qa", "mergeMethod": "squash" },
"merge": { "keepBranch": true }
}
CI: required checks and timeouts
The ci step reads the pull request’s checks every 20 seconds through gh pr view --json statusCheckRollup and decides in one of two ways.
ship.ci.requirednames checks: only those count. Each named check must appear, finish and pass, every run of that name when several share it. Other checks, such as a Vercel preview, are ignored. This is the safer setting: a pull request that gets only a host’s preview check and no CI must not read as CI passing.ship.ci.requiredis empty (the default): every check counts, and CI is detected by settling. All the checks must be finished, and stay finished with no new check appearing, forship.ci.settleSeconds(90 by default), so a fast check that finishes before CI registers does not pass alone. Skipped and neutral checks count as passed. No check ever appearing means CI never started.
ship.ci.timeoutMinutes (30 by default) is how long to wait in all. On failure the step exits 2 with one of these reasons: “CI failed: <names>. Read it with: gh run view –log-failed”, “CI never started on the pull request”, “CI did not finish within N minutes”, or “Could not read the pull request’s checks”.
A fine-grained GitHub token has no permission to read check runs, so GitHub refuses it that call. The step then reads the head commit’s GitHub Actions jobs and commit statuses through the REST API for the rest of its run. For that the token needs Pull requests, Actions and Commit statuses read. Check runs that other apps post are not seen that way, so they do not count; a classic token with the repo scope sees them.
The merge
The merge step runs gh pr merge <number> --<method> and then asks GitHub for the pull request’s state. Anything but MERGED is a failure with exit 1; a non-zero exit from gh alone is not, since a merge that did happen must not read as one that did not. From here the release has happened, and the tidying is best effort: the remote branch is deleted unless keepBranch is set, the base is fetched, the checkout moves to the newest base (detached, in a worktree), and the local branch is deleted unless keepBranch is set.
The step records the sha of the branch the pull request merged into, which deploy-github reads. When ship.pr.into differs from the base, that branch is fetched to find it; if the fetch fails, the fact is null, a warning goes to the event feed, and a deploy step after this one stops.
The merge is a point of no return. A failure in any step after it exits 5 and writes HALT, whatever the step’s own code would have been.
When the base moved
CI can take a while, and another story may land on the base branch meanwhile. mergeable-recheck catches that just before the first point of no return (the hosted migration, or the merge). When the base is no longer an ancestor of the branch, the step fails with again: true, and foreman ship handles it itself: still holding the ship lock, it drops the facts of that pass and runs the steps again from the first, so merge-main brings the base in and CI runs again. The event feed says “<base> moved while <id> was releasing; bringing it in again (n of 5)”. It does this at most 5 times in one run; after that the step’s failure is returned, saying the base kept moving. The pr step raises its review event only on the first pass. The loop-back never happens once a point of no return has passed, a deploy has passed, or a step marked no-rollback has started: then the failure is handled like any other.
A pull request GitHub reports as no longer mergeable (CONFLICTING, for instance) is not looped on, since it would merge cleanly here and only burn CI runs; the step exits 1 and leaves it to the story session.
Exit codes
The last line foreman ship prints is one JSON object { ok, code, stage, reason, ... }, with the facts the steps recorded (pr, prNumber, sha, conflicts, failures, migrations, live). ok is true for 0 and 9. The story session reads the code and follows its playbook.
| Code | Meaning | What the story session does |
|---|---|---|
| 0 | Shipped. | Closes the story with foreman story done, noting what now works, how it was verified, the pull request, and that it is merged and live. |
| 1 | Nothing to ship, or the branch is in the wrong state; the reason says which. | At mergeable-recheck, ships once more at no cost in rounds; a second time, blocks the story and ends it as failed with cause environment. Otherwise fixes what is its own to fix (uncommitted files, a missing commit) or ends as failed. |
| 2 | CI, the Salesforce validation, or the project’s checks on the merged code failed. Nothing merged. | A failed check or CI job becomes a fix round for the builder, then a new commit and another ship; a second failure takes the failed path. CI that never started, did not finish or could not be read is not the code’s fault: the story is blocked with the reason and ended as failed with cause environment. |
| 3 | Hosted credentials are missing. Nothing merged. | Blocks the story and adds an owner task naming the credentials. |
| 4 | The migration failed. Nothing merged; HALT written. |
Blocks the story, adds an urgent owner task with the ship JSON, and stops as halted. It never retries, reverts or repairs production. |
| 5 | The deploy or a check after it failed, or a step failed after a point of no return. HALT written. |
As for 4. |
| 6 | The branch conflicts with the base in real code. Nothing pushed. | A conflict-only fix round asking the builder to merge the base and resolve the files listed in conflicts, then a new commit and another ship, up to the project’s rounds. |
| 7 | An exclusive resource is held by another slot. Nothing pushed. | Leaves the branch committed, notes “waiting for <resource> to be free”, and stops as deferred. |
| 8 | Releases are frozen by HALT. Nothing done. |
Stops as held; the run releases the slot when the owner says the cause is fixed. |
| 9 | Parked: ready to promote. The release finishes later; nothing halted. | Ends the story as ready-to-promote. See Parked releases. |
| 10 | The owner must allow Salesforce metadata deletes. Nothing pushed, nothing halted. | Asks the owner through a decision, or sends the story back when the owner wants nothing deleted. |
HALT is written by a failing step marked as halting (deploy-github, smoke, watch and the Salesforce deploy steps), by any failure after a point of no return, and by codes 4 and 5. When a step after a successful deploy fails and ship.rollback.command is set, the run also rolls production back before it halts. Release safety has the whole of that. With features.jiraComments on, a release that stops with 2, 4, 5, 6 or 10, or with 1 from a step, is also written on the story itself: what stopped, why, and the pull request.
Was this page useful? Something missing or wrong?Email us and say which section.