Review and publish
The review queue spanning every collection and page, the build gate that runs before any push, and the live check that reads your site's commit stamp.
/admin/review/ is workspace-wide, and deliberately so. Publish sends a branch, so every fact on this screen — the ahead count, the unpushed commits, the button’s own number — is repo-wide and always was.
The queue
A real diff against what the remote has, joined to your collections. Each card is a change, and a change is either an entry or a page — the queue has spanned both since pages became composable.
Three standings:
| Standing | Means |
|---|---|
| Uncommitted | changed on disk, not committed |
| Draft | committed, on the staging branch, not yet published |
| Live | on the publish branch |
An entry card with a draftField carries a visible/hidden switch. Flipping it is an ordinary content save — editor-allowed, conflict-checked against that change’s own digest — not a special verb.
A page’s props are diffed only when its section list has not moved. Otherwise a reorder would read as six separate edits.
The Approve and Request-changes buttons were deleted rather than wired. Push sends the whole branch, so per-entry approval had nothing to act on. Pretending otherwise would have been a control that did nothing.
The rail
Branch — where Publish pushes. Admins get a combo box; editors get a readout. Typing a name nothing lists yet is legal: the name is validated by git check-ref-format rather than by a regex, stored immediately, and created on the remote by your next publish. The list’s last row is a New branch action for exactly that.
Unpushed commits — read straight from git, because git keeps no push log of its own.
Preview your changes — hidden until you have set a staging preview URL in Settings, because a branch deploy’s URL is platform-specific and only you know it.
What Publish does
Admin-only, and it takes no input. In order:
- Reads the branch from the settings store — never from
admin.config.ts, which is only the default. - Pins a commit. The sha is read once and both gates examine that same sha. A save that lands mid-publish rides the next one.
- Validates every outgoing entry against your Zod schema, reading the bytes off HEAD. A malformed entry blocks the push and is named per field. An entry marked
draft: truestill blocks — it rides the branch even though nothing renders it. - Checks the licence. See Licence and activation.
- Runs your build command, killed at your configured timeout. This is the gate: a failing build refuses the publish and hands back a bounded tail of its output. The build writes to a throwaway directory, so it never overwrites the
dist/the container is serving. - Pushes
<sha>:refs/heads/<branch>. - Polls your production site until its
<meta name="build-rev">stamp is a descendant of what was pushed.
Nothing is ever pushed that did not build.
The three worded states
Never a spinner:
- publishing…
- Your changes are live ✓
- this is taking longer than usual
The stamp is read server-side, because your site is usually another origin and CORS bars a browser from reading it. The comparison is ancestry rather than equality, so a commit landing on top of yours still reads as live.
Every refusal, and its next step
| Refusal | What to do |
|---|---|
| blocked | A frontmatter value fails your schema. The field is named. Fix it and publish again — this one is never throttled, because it never reached a build. |
| build-failed | Your build command exited non-zero. The tail is on screen. |
| rejected | The remote has commits you do not. Press Update from the site’s repository — a fetch and fast-forward of your own branch. Only genuine divergence, where both sides hold commits the other lacks, still names a terminal. |
| auth | The push credential is missing or wrong. See Environment variables. |
| no-remote | This checkout has no remote by the configured name. |
| busy | Another publish is in flight. It refuses rather than queueing. |
| cooling | A 60-second per-account cooldown, armed around the build. It carries the seconds remaining. |
The cooldown has one consequence worth knowing: publish → rejected → sync → publish again now waits. Clearing the cooldown on rejection would let a diverged checkout rebuild forever.
Auto-push
A landed commit pushes its own branch to its own name, debounced about three seconds. That is what makes the staging preview live: you save, the commit lands, the branch pushes, your staging deploy rebuilds with PREVIEW=1, and drafts render there and nowhere else.
Auto-push failure is a log line, never a dialog. The symptom you will actually notice is the site panel’s unpushed count not draining.
It defaults off in a development tree, so a save on your laptop cannot reach the real remote by surprise. The settings row says which way it landed.
Troubleshooting
Publish says “no identity”. The deployment’s GIT_COMMITTER_NAME / GIT_COMMITTER_EMAIL are unset. Commits are authored by the signed-in account and committed by that identity.
The publish button’s count looks wrong. It counts commits ahead of <remote>/<publish branch>, which is not the same as the upstream your auto-push drains. On a first publish to a branch the remote does not have yet, it counts every commit — which is the honest answer.
“Your changes are live” never arrives. Either your production build is slow, or the page answering / has no <meta name="build-rev">. Publishing still worked; only the confirmation depends on that line.
The staging deploy does not show drafts. PREVIEW=1 is missing from that deploy’s build environment. It is the one flag that lets drafts render.
Nothing appears in the queue although files changed. git diff cannot see an untracked file, so the queue unions the diff with status output. If something is genuinely missing, it is a bug worth reporting.
Publish refuses with a licence message. See Licence and activation — and note that an outage is not one of the refusals, because it rides a fourteen-day grace.