On this page

Rule zero: Promote is operator-hand

A promotion moves code onto a running environment, restarts processes, and can apply schema migrations. An agent must not run one. Read the ladder, explain a halt, prepare the commit, and hand the decision to the operator. A dry run (dryRun: true) is the agent-safe path: it mutates nothing and stops after the plan rung. The dirty-tree override in particular requires a human reason that is recorded in deployment history — supplying one on an operator's behalf defeats the control.

The ladder is configuration

A project's deploy_config.promotion.stages is an ordered array of {name, branch, path, pm2?, health?, migrate?}. Fewer than two stages, an unknown stage name, or a stage missing branch/path is a 400. Promotion climbs exactly one rung: demotion and rung-skipping are refused 400 with the ladder printed. GET /api/projects/:id/promotion-status reads state without touching anything: per stage sha, checkedOut, dirty/dirtyFiles/ dirtyTotal, declared pm2/health, lastDeploy from the journal; and per adjacent pair a rung with state of up-to-date, ahead (with ahead count), behind, diverged or unknown.

The rungs, in order

Every rung is a hard gate that reports its own step ({name, ok, detail}) and halts with an HTTP status; nothing proceeds on a failure, and nothing runs automatically — the route is explicit-trigger only.

#RungWhat it doesHow it fails
1verifyboth stages: a git repo rooted at path, checked out on the declared branch, clean tree409 — not a repo (naming an enclosing repo if there is one), wrong branch, or uncommitted changes (first 400 characters quoted)
2planresolves both HEADs, lists the commits to move (capped), proves fast-forwardability, and discloses whether the delta touches dependency manifests, migrations or process-config files409 — histories cannot be related, or the target holds commits the source lacks (not fast-forwardable). Identical shas short-circuit as upToDate. dryRun returns here with installNeeded, migrationsNeeded, migrationsChanged, processConfigsChanged, configApplyNeeded
3testruns the source stage's suite (configurable argv, default npm test) in a sanitized child environment409 — "refusing to promote a red build", with the output tail. Skipped cleanly when the source has no test directory or the gate is disabled
4push-sourcepushes the source branch to its first remote502 on push failure; recorded as skipped when there is no remote
5advancefast-forward-only merge of the source sha into the target branch, then pushes the target if it has a remote409 when a separate target repository has no remote to receive from, or when the ff-only merge refuses; 502 on fetch/push failure. Never invents a merge commit
6installdependency install in the target checkout, only when the delta touched a manifest; npm ci by default, so the committed lockfile is installed verbatim and never rewritten500 on install failure — the old process is still serving old code from memory, so the target stays up
7migrateapplies migrations to the target database, only when the delta touched the migrations directory409 when the stage declares no migrate.dbUrlEnv, when that variable is unset, or when it resolves to the promoter's own DATABASE_URL; 500 on migration failure. It refuses rather than promote schema-dependent code unmigrated, or migrate the wrong database
8restartpm2 restart <name> when the stage declares one500 on restart failure
9configreport-only fidelity check: if the delta changed a pm2 ecosystem file, the step is marked not-ok and explains that pm2 restart reuses the definition captured at pm2 start and will not re-read itnever blocks; the code deployed correctly, the process config did not
10healthHTTP probe of the stage's health URL with bounded retries; any status below 400 passes500 with the last error after the configured attempts

On success a commander.deployments row records version (source sha), source and target env, triggered_by, the reason, the full step log and the commit list. A failure records a failed row too — except a dry run, which never writes history.

Failing after advance

Once advance is green the target checkout has already moved, so a later halt returns an explicit rollback object: the previous head and the exact commands that restore it (git -C <stage path> reset --hard <prevHead>, plus a pm2 restart when the stage declares one). The ladder never leaves a target silently half-promoted.

Autoclean

A promotion target legitimately accumulates install artifacts. Before calling a target dirty, the verify rung restores allowlisted, tracked-modified paths to HEAD (named default: the lockfile) and emits an explicit verify-autoclean step — the cleanup is never silent. Anything else dirty (untracked, added, deleted, renamed, or a non-allowlisted path) still halts, and a failed restore halts too. Autoclean never applies to the source: a dirty source is uncommitted work.

The audited override (FEAT-046)

override: {dirty: true} with a non-empty reason lets an authenticated operator pass a dirty-tree verify halt. The contract:

(overridable: true) and, when it would not, overrideBlockedBy.

target is also changed by the promoted delta, the merge would refuse or clobber it, so the halt stands regardless of intent. Unparseable or quoted porcelain paths also keep the halt, because the conflict check cannot prove it saw every path.

deployment history. Source dirt never rides a promotion — only committed HEAD moves.

Structural refusals (wrong branch, no repo, non-fast-forwardable) have no override at all.

Rollback

GET /api/projects/:id/rollback-candidates lists recent commits on a stage's own branch, annotated from the deployment journal so previously deployed shas stand out. POST …/rollback rewinds that stage with the same reset the promote engine prescribes, then re-runs the post-landing rungs. A rewound stage stays an ancestor of its upstream rung, so the next promotion fast-forwards straight back over it; revert commits on the target branch would break that invariant. Discarded commits remain reachable through the reflog. Rollback is operator-hand.

Container deployment and the signed version manifest

deploy/ holds the tenant image and its contract: a Dockerfile, a tenant compose file, an env example, and version-manifest.schema.json — JSON Schema draft 2020-12, $id under the vendor schema namespace. One manifest is one immutable release. Required: schemaVersion: 1, service (commander or guardian), version as SemVer 2.0 core, image.repository plus an image.digest of the form sha256:<64 hex> (an OCI digest, not a mutable tag), versionLock pinning the Claude Code, Node and PostgreSQL majors, migrations.from/ to, a non-empty preflight[] of {id, kind, severity} checks from a fixed kind set with severity block or warn, a rollout.strategy of blue-green or recreate, and the freshness/binding claims iat, exp and tenant that make replay, downgrade and cross-tenant reuse detectable.

The manifest is signed into a compact JWS (RFC 7515) with alg: EdDSA and a kid that is the RFC 7638 thumbprint of the public key; the payload is the manifest object, not a JWT claims wrapper, so a verifier checks the signature and then parses the payload. The deploy signing key is deliberately separate from the agent-manifest key: a deploy manifest authorizes pulling an image and swapping a live container, so a leaked agent-manifest key must not be able to sign one. The signer validates before signing and refuses both an invalid manifest and a non-Ed25519 key; Guardian holds the public key by kid and verifies before applying. scripts/release-catalog.mjs publishes a commit to the build catalog behind its own gates — clean tree, SemVer, duplicate check, per-file digests plus migrations and build info, then the record, the artifact and a listing verification. Both scripts are operator-hand.

commander-observability (dependency locks, process and health signals), commander-security (why the ladder is server-side and what else is operator-hand).

You've seen the proof

Ready for a number? Scope your deployment and we'll price it against your own economics.

Get your quote →