Release runbook
The ordered walk for shipping a release, from a clean main to verified listings. It covers both repos, because a release is one event across the two: the server publishes to npm, the toolkit publishes to the Godot listings, and each gates on a pinned revision of the other.
This file is byte-identical in both repos, like RELEASING.md. Edit both copies and cmp them. Paths are relative to the repo each step names; an unqualified path is a server-repo path, since the release scripts and the probes live there.
It orders and links rather than restates. The mechanics live where they already are:
RELEASING.md(both repos): therelease.shritual, the sibling-pin ritual, the dry-run rehearsal, rollback.docs/dev/release-checklist.md(both repos, different content): the manual gates CI cannot reach.test/probes/README.md(server): the drivers that produce the checklist’s evidence.
Work through the phases in order. Each one ends in a state the next phase assumes.
Before you start
- Both repos on
main, clean, pushed, with CI green on both HEADs. The release script checks CI itself throughgh(2.48 or later), reading every page of check runs, and stops on anything not green. It asks you to confirm only whenghis missing, and a non-interactive run refuses instead of asking. - Each repo’s sibling pin postdates every cross-repo contract change since the last release. A pin taken before a contract change certifies the wrong pairing, and every behavioral leg then fails identically, which reads like a code fault.
- Generated docs are fresh (
npm run docs:api && npm run docs:tools, server). The release script andrelease.ymlboth check this; running it early keeps the fix cheap. npm auditclean, or every remaining advisory understood and recorded.release.ymlre-runs a production-only audit at high severity before it publishes.- Release both repos at the same version, server first. They version separately by design, but each repo’s tag gate requires the pinned sibling to declare the exact version being tagged. Until the tag gates check the declared floor instead, that forces a lockstep release.
- Run the
humanizerskill over the[Unreleased]entries in each repo’sCHANGELOG.md, and edit them before anything is tagged. Release notes are the most-read text either project ships, and the annotated tag embeds the rolled section permanently, so a tell caught afterwards cannot be fixed without the file disagreeing with the tag. Em dashes are the usual finding. Applies to new entries only: entries already shipped in a tag stay as they are.
Phase 1. Rehearse
Fire each repo’s release.yml through workflow_dispatch with dry-run: true. The full gated path runs; publish and GH-Release steps skip. The dispatch input defaults to true, so a dispatch can never publish by accident.
A rehearsal certifies the pinned sibling, not the two HEADs. If every behavioral leg fails identically, suspect a stale pin before suspecting the code.
Phase 2. Walk the manual gates
Work through docs/dev/release-checklist.md in both repos. This is the half CI cannot reach. The server side covers connection stability, security boundaries, cross-subsystem flows, dispatch integration, and the supply-chain audit. The toolkit side covers the export-safety regression, concurrent human and MCP editing, the macOS GUI-launch smoke, and the dock and Project Settings round-trip.
The macOS section is blocking if a Mac is available and a documented coverage gap if not. Never record it as a silent skip.
Four probes print the evidence for the server checklist’s §1 and §2 items, so you observe rather than improvise. Run them from the server repo with the local tsx (never bare npx), against a live editor:
| Checklist | Probe |
|---|---|
| §1 B1-B8, connection stability | test/probes/connection-stability-driver.ts |
| §2 D2, D3, D5, path guard and auth | test/probes/path-guard-and-auth-probe.ts |
| §2 D4, read-only surface | test/probes/read-only-surface-probe.ts |
| §2 D5c, D7, oversized inbound | test/probes/oversize-request-probe.ts |
test/probes/README.md says what each one establishes and how to invoke it.
The release script asks you to confirm this walk happened, and aborts if you decline. In a non-interactive run the gate dispositions file answers it instead. It is the only gate between a broken interactive path and a published release, so do not confirm it from memory.
Patch releases
A patch (x.y.Z) runs every automated gate in full: smoke, flows, dispatch integration, both unit suites, smoke:ci, and the supply-chain audit. It walks only the checklist sections its change touches. Before you answer the script’s gate, write the gate dispositions: every section walked, skipped with a reason, or a documented gap with an owner.
Escalation goes by trigger:
- transport or process lifecycle → server §1;
- security boundaries → server §2;
- the export path → toolkit §1;
- editor lifecycle, or anything human-facing in the editor → a hand walk of the affected surface, written for the change.
The full gate (all of them) is for minor and major releases. Where a section needs hardware you do not have, take its own documented-gap path rather than skipping it silently, and give the gap an owner. First applied: 1.0.1 (server §1 incl. B9 and §5; the toolkit’s macOS GUI-launch smoke recorded as a documented coverage gap, since no Mac was available).
Architecture and contract freshness
Before you write the gate dispositions, run both freshness checks: bash scripts/check_arch_freshness.sh in the toolkit repo, which covers docs/architecture/README.md and docs/dev/contract.md, and npm run check:arch in the server repo. Record each repo’s stale count in the dispositions. Staleness the release inherits is advisory: record it and carry on. Staleness the release introduces blocks it. An entry is the release’s own when a commit since the previous release tag moved one of its depicted files. Before this walk, whoever made that change must re-read the affected diagram or contract section against the code and set its data-verified to that commit or a later one. The re-attestation is always a separate commit, because a commit cannot name its own SHA.
Phase 3. Bump the pins, serialized
Each repo pins the sibling revision it was tested against (SIBLING_PIN_TOOLKIT in the server’s cross-version.yml, SIBLING_PIN_SERVER in the toolkit’s), so both pins move to the revisions you are about to release. Bump each in a dedicated commit carrying [run-cross-version-ci]. That commit is the tested-integration event.
Bump the consumer first and let it go green before touching the other. Both repos fire the same shared behavioral composite, so two correlated runs at once burn twice with no fix in between.
Add the all-platforms trigger token to the commit body to run the full grid (three operating systems by six Godot versions by two editor languages, 36 combos). This is the only point in the walk where the full grid runs: a tag push or a release dispatch yields an empty non-Linux matrix by design, so the tag-fired gate is Linux-only. Tokens fire from the body even when they appear as ordinary prose, so never write a real token literally in a commit message that is not meant to fire it.
Confirm the aggregate jobs report success by name, not just that the run finished: Platform matrix OK and Behavioral gate OK. A matrix can report success with every leg skipped. Sanity-check the behavioral leg count against the matrix header comment at the top of cross-version.yml, and record both run URLs. A red leg stops the release.
Phase 4. Release the server
Read Workflows/release-script-gotchas.md in the plan repo if you are touching the script itself.
Rehearse the script first, then run it for real:
./scripts/release.sh <version> --dry-run
./scripts/release.sh <version>
An agent-driven release writes the gate dispositions first, then answers the prompts with flags:
./scripts/release.sh <version> --dry-run --non-interactive --gate-dispositions <file>
./scripts/release.sh <version> --non-interactive --gate-dispositions <file> --changelog-curated
There is no pause to curate in, so [Unreleased] must be final and committed before the run. RELEASING.md describes the flags.
For a change that genuinely spans both repos, `./scripts/release.sh