← All posts

What is live where - Ship as the record of every deploy, not the thing that runs it

Rezee's Ship layer records deployments instead of performing them. Environments, three ways to report a deploy, a grid of what runs where, rollback and promote as plain verbs, release trains that never execute anything, and four delivery numbers you can open.

Sep 24, 2026 · 11 min read · Kash Gohil · LinkedIn · X

Ask a team what version of the API is in production right now. Then watch. Somebody opens the CI logs. Somebody else checks the registry tags. A third person searches Slack for "deployed". Ten minutes later you have an answer that two of the three agree with.

That question is what Ship is for. We wrote its position down before any code. Ship is the system of record for what is running where, and the orchestrator that moves it there. It never becomes a place to configure infrastructure. This post is how that sentence became tables, verbs and one screen, and what we decided to leave out.

We record deployments. We do not perform them

Everything else follows from this decision. Rezee does not deploy your code. Your pipeline step already runs flyctl deploy, vercel deploy --prod or kubectl apply, and that part works. What was missing is that when the step finished, nothing knew a deployment had happened. So Ship adds the record and leaves the doing to you. Three paths write it, and all three write the same row.

A deploy: key on a pipeline job. A job in .rezee/actions.yml names the environment it deploys to. Rezee opens a deployment when the job starts and closes it with the job's result. The commands in the job are untouched.

jobs:
  ship:
    deploy: production
    steps:
      - name: deploy
        run: ./scripts/deploy.sh

The name is resolved when the run is queued. A name nobody has created fails the run there, with a message on the run page that names it. A typo should not split the history of the place it meant.

rezee deploy from the CLI. For anything that deploys outside our CI: a shell script, a Makefile, a laptop. start opens a row and prints its id. Your script does its own work. finish closes the row.

id=$(rezee deploy start production --id)
./deploy.sh && rezee deploy finish "$id" || rezee deploy finish "$id" --failed

finish records a success unless you pass --failed. We chose that default on purpose. The failure branch of a shell script is the one people forget to write. A row stuck at running forever is a worse record than one marked good by mistake.

deploy_report and deploy_finish over MCP and REST. For an agent, a GitHub Actions workflow or a Jenkins job. A step that runs after a deploy already knows how it went, so deploy_report takes a status and closes the row in one call. These tools take their own deploy:write capability. A token that says what is running cannot also cut a release.

One more rule holds on the job path. If writing the row fails, the job still moves. A pipeline whose build succeeded must stay green even when our bookkeeping trips. A bug in recording should stay a bug in recording, not become a stuck pipeline.

One row per attempt, and the column that does the work

An environment is a name and an ordinal. The ordinal makes dev < staging < production a fact rather than a naming convention, and promote and the delivery numbers both depend on it. An environment can belong to the whole workspace or to one repository, because "production" is usually one place whichever repository deploys into it.

A deployment is one attempt to put a commit into an environment. It holds the repository, the sha, an optional release tag, a status, who asked, and when. The column that matters is supersedes.

A deployment never edits or deletes the one before it. It points at it, and it flips that row to superseded. So an environment's history is a list you read in order, and "what was live before this" is always one column away. Rollback, as you will see, needed no table of its own because of this column.

The design has one trap. Count deployments by status = 'success' and you get one row per environment and repository: the one live now. Everything replaced since is superseded, so every workspace would seem to deploy about once. Every reader that asks "what went out" counts both statuses.

Ship also offers Archive on an environment, never Delete. Deleting would erase what went to production, which is not what somebody retiring a place asked for. An archived environment takes no deploys, and its name stays reserved, so no deploy: staging key quietly points at whatever someone creates next.

The grid

Ship's landing tab is a table. Environments run across the top in ordinal order. Repositories run down the side. Each cell answers four questions:

  • What is live here: the tag if there is one, the short sha if not.
  • How far behind the default branch it is, in commits.
  • Whether a deploy is on its way.
  • Whether the last attempt failed.

We put four facts in a cell because one fact misleads. A cell with only the live version shows a green production while the deploy of the fix has failed for an hour.

In our demo workspace the grid reads like this. web runs v0.9.4 in production, four commits behind. api runs v0.9.3, 21 commits behind, with a staging deploy in progress. runner also runs v0.9.3 in production, and its last deploy there failed. You read that in about three seconds, and nobody had to open a log.

"Behind" asks the git server. When git cannot answer, the cell shows nothing rather than zero. "Up to date" is the one thing this screen must never invent.

What is waiting to go out

The Releases tab has a list called Not shipped yet. It is the set of pull requests merged to the trunk that no release contains, grouped by repository, with their issue keys and authors. In effect it is a pending changelog that keeps itself current.

When you cut a release, its notes start from that list, with headings taken from the labels on the issues the merges closed. Nobody has to start from an empty text box.

The record also flows back into the rest of the workspace. An issue reads "shipped in v1.4.2, production, 3h ago" without anyone linking anything. That badge is a join done when you read it, from the issue through its pull request and release to the deployment. An environment can also name a chat channel, and a deploy there posts in it as a card.

Moving an issue to Done on deploy is a per-project setting, and it is off by default. The badge always shows. Moving somebody's issue without being asked is the kind of help people switch products off over.

Rollback and promote are one verb

Both verbs send a sha that already succeeded somewhere into an environment, without building it again. Rollback's sha is the row the live deployment superseded. Promote's sha is the row live one environment back.

Neither verb writes a deployment row. Only your own deploy job can put a commit somewhere. So both verbs queue a pipeline run filtered to that job alone, at the named sha. Your test and build jobs do not run again. The commit is already built, and the environment you copy from is the proof that it works. The deployment row then arrives the usual way, so a rollback is written forwards and nothing is edited.

The refusals are part of the feature:

  • Only the deployment that is live can be rolled back.
  • Promoting into an earlier environment is refused, and the message names both environments. Only the ordinal knows which way is forwards.
  • A repository that reports deploys from outside our CI has no job to re-run. It is told so by name, rather than handed a pipeline that does nothing.

That last one is the honest cost of the "we do not deploy" rule. If rezee deploy from a laptop is your only path, Ship keeps your record, but the Roll back button has nothing to press.

Release trains, which never execute anything

A train moves one version through a plan. The plan is a file, .rezee/release.yml, reviewed in a pull request like any other code:

name: Ship
on_failure: rollback_stage
stages:
  - deploy: staging
  - bake: 30m
  - check: smoke
  - name: Sign-off
    approve:
      teams: [sre]
  - deploy:
      environment: production
      repos: [api, web]

There are four stage kinds, and we decided early there would not be a fifth. deploy queues your deploy job, the same call rollback uses. check runs one named job from your pipeline file and needs it green. bake waits. approve waits for people. A request for a fifth kind, such as "run a migration", has one answer: a script you write, in a job a check stage names. Writing that rule down while it cost nothing is what stops Ship from turning into a second CI engine.

The orchestrator itself runs nothing. A train is rows plus a tick. Each stage either finishes by itself, like a bake's clock, or hands work to something that already exists and waits. So a train can wait overnight for a person without holding a runner, which is why it is not just a very long pipeline job.

The plan is series-parallel, not a general graph. Stages run in order, and a deploy that names several repositories runs them as parallel lanes. We said no to a DAG because of rollback. Here every stage has exactly one state before it, so undoing is "walk back up the list". In a DAG where only one of two branches fails, whether the other unwinds has no clear answer. A release tool cannot have an unclear rollback.

Some rules live in the schema, not in good intentions:

  • One train per environment. A lock table has the environment as its primary key. A train takes every environment its plan deploys into before it starts, all or nothing. If it cannot, it is queued, and the list says which environment it waits for.
  • Pause stops the train, not the deploy in flight. A running deploy finishes and is recorded. The train keeps its environments while paused. The row keeps who paused it and why, because a train paused at 2am and resumed at 9am needs a name on it.
  • The person who started a train cannot approve it. One rejection fails the stage. An agent can start a train through train_start, which asks a person first, but it can never answer a gate. Agents are users, so that is one check, not a second identity system.
  • Rollback has three widths. Undo one stage, abort the train, or let on_failure do one of those for you. All three are the same motion: take the deploy stages that succeeded, newest first, and put back what each replaced. Each undo is an ordinary rollback, so it shows in the environment's history like any other deploy.

Before a deploy stage runs, Ship shows what it carries: commits, the issues they close, and the repositories that go. An approval stage shows the numbers of the deploy behind it, because "Approve?" over a stage name is not a question anyone can answer.

Four numbers, and every one opens

The Overview has four figures with a sparkline each, over twelve weeks:

  • Deploys to production per week. Successful deployments into your highest-ordinal environment.
  • Merge to live. The median time from a merge to the first deployment that carried it. Git answers "which deploy first contained this merge", so a team that deploys bare shas is measured the same as one that deploys tags.
  • Changes rolled back or failed. The share of attempts that failed or went out and were rolled back. The note under it is the median time to recover.
  • Builds passing, with CI minutes against your pool.

Three of those are DORA's measures, and the fourth sits under the third. Each figure is a button that opens the rows it was computed from. There are no Elite, High, Medium and Low grades. DORA moved away from those in 2025, and a number you can open and argue with is worth more than a grade you cannot.

Nothing marks a rollback in the data, because rollback writes forwards. So the stability figure recognises one: a successful deploy of a sha this lane already ran, going back past the one live now. The deployment it went back past is the change that failed. The recovery time runs to the rollback, or to the next success for a deploy that never landed.

Before these numbers went on the site, we recorded a run behind them. It folds twelve weeks of seeded data with known answers through the shipped code. It found a real defect. A rollback deploys an old commit again, and the lead time kept the later of its two rows. So merges were dated to the rollback, not to the day they shipped. The earliest row wins now, and a test holds it there.

Two limits are worth saying plainly. Merge to live starts at the merge, not the first commit, so it is shorter than DORA's lead time for changes. And a bad change you fix forward, with a new commit and no rollback, does not count as a failure. The data has no way to know that the second deploy was a fix.

What Ship is not

It does not configure infrastructure, and it has no connector per hosting provider. The contract is the integration: a job key, a CLI call or an API post. It records deployments of repositories hosted in Rezee, so it is not a dashboard you bolt onto code that lives somewhere else.

The record is on every plan, including Free: environments, the grid, rollback, promote and the four numbers. A team that cannot see what is in production is a team we have not helped. Release trains, with plans, gates and bakes, are on Startup and up. The pricing page has the line. The rest of the layer is in Code & Ship, and the walkthrough of that layer covers the pipelines underneath.

The test we hold Ship to is the one from the start of this post. Ask what is in production. If the answer takes one screen and no scrolling through logs, the record is doing its job.