Releasing
How the version number moves and what pushing a tag builds. This is a maintainer’s page — it describes the process a release goes through, not anything a user or a preset author needs.
The scheme is decided in ADR-0005; what follows is the operational summary.
One version, one command, once per plan
- Single source of truth: root
Cargo.toml[workspace.package].version. Every workspace member inherits it (version.workspace = true). The studio holds two copies thatcargo-releasedoes not move —studio/package.json"version"andEXPECTED_PLAYER_VERSIONinstudio/shared/protocol.ts— see “The studio’s two copies” below. - Bump authority:
cargo-release, a dev tool installed withcargo install cargo-release(not a workspace dependency). Config is inrelease.toml. - Cadence & owner: one bump per shipped plan, run by the architect in the close
ceremony — after the plan flips to
doneand its docs land. Not per phase commit. - No push:
cargo-releasestages the version edit and writes thevX.Y.Ztag but does not push; the user pushes (project no-auto-push rule).
A close reports a red main and never blocks on it
The close reads origin/main’s CI before it merges, reports what it read, and merges whatever
it read
(ADR-0251).
The tag a close writes has no CI result yet, since CI runs after the push. What the close can read
is the tip it merges onto:
node scripts/check-upstream-ci.mjs # exit 0 green or not read, exit 1 redIt reads the newest completed run of the CI workflow on main, passing over a cancelled one, and
never a Pages or Release run. Its three outcomes are spelled differently, and the script’s exit
code is for a person reading it by hand; no close stops on it:
| Output | Exit | Where the close reports it |
|---|---|---|
upstream CI: OK - run <id> ... | 0 | the conductor’s live log |
upstream CI: RED - run <id> ...; failing job(s): check (macos-latest) | 1 | the live log, as one line naming the failing jobs, and a line in tools/conductor/digest.md’s Needs you that stays until a later close reads main green |
upstream CI: skipped: not read (<case>) - ... | 0 | the live log, recording that nothing was checked. The cases are no origin, gh absent, gh unauthenticated, no network, and no completed run |
Why it never blocks: the conductor never pushes, so origin/main moves only when you push. A
close that waited on a green origin/main would wait on a step no session can take, and a queue of
approved plans would stop behind it. Local closes also run ahead of origin, so the reading describes
an older tree than the one being closed. The reading is a report, and acting on it is yours:
repair the failing job on main, not in the plan’s lane, and push.
The reading needs gh auth login on the machine doing the close, once. A machine without it closes
as before, and prints the not-read line each time.
Commands
# Preview — cargo-release is dry-run by default, so this changes nothing:cargo release <patch|minor> --no-push
# Do it (bumps the workspace version, commits, tags vX.Y.Z, no push):cargo release <patch|minor> --no-push --no-publish --no-confirm --execute--execute is what makes it real; without it cargo-release only reports what it
would do. push = false / publish = false are already pinned in release.toml
— the explicit flags on the command line are belt-and-braces.
The studio’s two copies follow, then the tag moves
cargo release edits Cargo.toml and Cargo.lock only. Straight after it, bring the studio’s
two copies to the same version in their own commit, then move the tag onto that commit so a
build of the tag carries all three:
# edit "version" in studio/package.json and EXPECTED_PLAYER_VERSION in studio/shared/protocol.ts(cd studio && npx vitest run shared/version.test.ts) # holds all three equalgit commit -m "fix(studio): the two version copies follow the workspace to X.Y.Z" -- studio/package.json studio/shared/protocol.tsgit tag -a -f vX.Y.Z -m "chore: Release vX.Y.Z"node scripts/check-release-tag.mjs # the tag exists, is annotated, and is on HEAD's historyMove the tag with -a -f, never with git tag -d and a plain git tag. -f moves it; -a
keeps it an annotated tag object carrying the message cargo release wrote. A plain git tag
writes a lightweight tag, and the push below never sends one of those
(ADR-0203).
EXPECTED_PLAYER_VERSION is the copy that ships wrong. The packaging scripts override
package.json’s version at build time, but the constant is compiled into the renderer bundle, so a
stale one ships a studio that refuses the player packaged beside it — a refusal indistinguishable
from a genuinely mismatched bundle. studio/shared/version.test.ts fails the studio’s suite (and
the pre-push studio step) until both copies follow. Five separate fix(studio) sync commits
exist because this step lived nowhere a close would read it.
While in the 0.x band: a feature-plan is a minor bump (0.1.0 -> 0.2.0), a fix-only
plan is a patch bump (0.1.0 -> 0.1.1), and a docs/chore-only plan legitimately gets
no bump (choose the level deliberately — this is not a missed step). Reaching 1.0.0 is
a deliberate future act (freezing the C ABI and standalone behavior), never backed into.
Pushing the tag is what builds the artifacts
cargo-release writes the tag; pushing it is what produces downloadable builds. The user
pushes — the architect never does.
git push --follow-tags origin main--follow-tags sends annotated tags only, and only those pointing at a commit the push carries.
A lightweight tag stays on your machine without a word, which is how eight of the ten tags stranded
on 2026-09-14 got there; two annotated ones were left behind by whatever push was actually run. So
the property is checked rather than the habit: the pre-push hook runs
node scripts/check-release-tag.mjs, which refuses the push while the version main declares has
no annotated tag on HEAD’s history, and CI’s links job runs it with --remote on every push to
main, which goes red until origin advertises that tag as annotated.
Push one tag, not a backlog of them. GitHub does not start workflows for tags pushed in bulk, so
a git push --tags carrying more than three of them fires nothing at all — no Release run, no
artifacts, and no error to read. That is how v0.113.0 came to exist on origin with zero Release
runs: the 2026-09-10 history rewrite force-pushed 131 tags in one command. The recovery is to delete
the remote ref and push it again on its own, because re-pushing an unchanged ref emits no event
either:
git push origin :refs/tags/vX.Y.Zgit push origin vX.Y.ZThat fires .github/workflows/release.yml
(ADR-0038,
ADR-0115),
which builds the macOS, Windows and Linux standalone
(ADR-0131), the
foobar2000 component and the two studio zips
(ADR-0178) in parallel and, only if every one is
green, publishes a GitHub prerelease carrying five zips and one tarball:
ritmolux-v<version>-macos-universal.zip # universal .app, ad-hoc signedritmolux-v<version>-windows-x64.zip # ritmolux.exeritmolux-v<version>-linux-x64.tar.gz # ritmolux, x86_64, built on ubuntu-24.04ritmolux-v<version>-foobar2000-component.zip # foo_ritmolux.fb2k-component, x64ritmolux-studio-v<version>-macos-universal.zip # universal Studio.app, ad-hoc signedritmolux-studio-v<version>-windows-x64.zip # Ritmolux Studio.exeEach carries a READ-ME-FIRST.txt; the standalone archives also carry a reference copy of
presets/*.toml. The two studio zips carry a player of their own, at resources/player/
inside the application, so a tester who takes the studio needs nothing else — that is the whole
reason the studio is a release artifact rather than a checkout-only tool. If any build fails, the
release job is skipped and no release exists — there is no half-published state. Re-running
the same tag’s workflow replaces the assets rather than failing.
The verify job asserts exactly five zips and exactly one tarball, counted per kind, so a job
that silently stopped producing its artifact fails the release instead of shortening it — a count
of zips alone would pass a release that shipped nothing for Linux. The release job needs
verify, so it never publishes a set that failed the count, and both gh release commands upload
the two kinds that were counted. The count is scripts/check-release-assets.mjs, and verify
runs on a dispatch as well as on a tag push, so a rehearsal checks the assets exactly as a release
does (ADR-0254).
A release run can only show the count passing, so its --self-test proves it refuses a short set on
every push instead.
Every archive’s name carries the version from [workspace.package], the studio’s included.
studio/package.json has a version field of its own and cargo-release does not touch it, so
the two packaging scripts override it at build time (-c.extraMetadata.version) rather than read
it. The macOS script then reads the version back out of the built Info.plist and fails if it
disagrees with Cargo.toml — which is what stops the studio from ever shipping a stale version
string.
A tag push is outward-facing. Since ADR-0038 this is the last step of every close ceremony
whose tag gets pushed, so every plan close now publishes a public prerelease whether or not that
build was meant for anyone. --prerelease while in 0.x softens the implication; it does not
remove it. If a close should not publish, do not push the tag — and expect main’s CI to go
red on its next push: the links job’s release-tag step fails until that version’s tag reaches
origin, or until the next close bumps the version past it. That red run is the documented cost of
holding a tag back, not a fault to chase.
Editing anything under .github/workflows/ needs the workflow OAuth scope on the git
credential. Without it the push is rejected with a scope error that names neither the file nor
the fix:
gh auth refresh -s workflowTo rehearse the builds, run the workflow from the Actions tab (workflow_dispatch): it produces
every archive as run artifacts, and its verify job runs the same per-kind count a release
does. Note that a workflow_dispatch is only offered once the
workflow file exists on the default branch.
A dispatch never publishes, on any ref — a tag included. The release job’s condition names
the event as well as the ref, if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'), so the rehearsal is safe wherever you launch it and you do not have to pick the
ref carefully to stay out of trouble. Plan 0165 Phase 2 made that true; before it, the condition
read the ref alone, a dispatch on a v* tag satisfied it, and the safest-looking rehearsal was
the one that published — which is not hypothetical here: run 31955362251 published v0.70.0
from a dispatch on that tag.
The consequence: a dispatch is no longer a way to publish a tag whose push produced no run. That recovery is the delete-and-re-push above, and it is now the only one.
The component job can also be rehearsed locally, and unlike the macOS bundle it runs on the box this project is developed on:
.\packaging\foobar\fetch-sdk.ps1 # once; idempotent.\packaging\foobar\build-component.ps1 # same script, same checks, as CI runsSo can the Windows standalone, which needs the Spout SDK because the shipped exe is built with the feature, and the Windows studio, which carries a player built the same way:
.\packaging\spout\fetch-sdk.ps1 # once; idempotent.\packaging\windows\stage.ps1 # same script, same checks, as CI runs.\packaging\studio\build-studio.ps1 # same script, same checks, as CI runsThe Linux tarball rehearses on any Linux box with pkg-config and libpulse’s headers installed —
the same script and the same checks as CI’s linux job:
bash packaging/linux/stage.sh # --skip-build reuses target/release/ritmoluxIt is not the shippable tarball when built anywhere newer than the runner: the binary requires
the glibc of the machine that linked it, and the release job’s ubuntu-24.04 is the floor the release notes
name.
The Windows studio’s macOS sibling is packaging/studio/bundle-studio.sh, and like
packaging/macos/bundle.sh it needs a Mac — the ad-hoc signature, the lipo calls and the plutil assertions have no Windows
equivalent. Both take --skip-build / -SkipBuild to reuse the release binaries and the
installed node_modules, for iterating on the zip’s layout without paying for an lto = "fat"
rebuild.
A tag that did not reach origin
The gate reads only the version at the tip, so two bumps before one push can still strand the
older tag. List every local v* tag origin lacks — a listing, never a failure, and it names no
batch/* scratch ref:
node scripts/check-release-tag.mjs --stranded # one line per tag: name, annotated or lightweightRepair each one on the commit it already names, then push it by name, never with --tags:
git tag -a -f vX.Y.Z 'vX.Y.Z^{commit}' -m "chore: Release vX.Y.Z" # only if it printed lightweightgit push origin vX.Y.ZOne tag per push publishes one release. Several in one push can fire nothing at all (see “Push
one tag” above), so if a whole backlog of them should reach origin without releases, disable
release.yml and ci.yml for that one push rather than rely on that suppression.
While you are here: read the two sizes
Two of the artifacts carry a soft cap, and you no longer have to measure either. Each recipe reads its own output’s length and prints it beside the cap:
-
foo_ritmolux.dll— 12,582,912 B (12 MiB) (Non-functional requirements §4, ADR-0159). It grew +910,848 B between Plan 0097 and Plan 0141 without anyone watching, none of it attributed as it landed, which is why the recipe now reads it:foo_ritmolux.dll is 9789952 B (77.8 % of the 12582912 B cap)Past 11,324,620 B — 90 % of the cap — that step emits a warning instead of a check mark.
-
ritmolux.exe— 16,777,216 B (16 MiB) (docs/nfr.md§4, ADR-0231). It sat 9.7 % over the inherited cap for an unknown time because nothing read it. Both standalone recipes now do —packaging/windows/stage.ps1for the exe,packaging/macos/bundle.shfor each Apple slice on its own, since the universal file is two executables — and each prints the build beside the number, because a size is a property of a build rather than of the tree:ritmolux.exe is <bytes> B (<share> % of the 16777216 B cap)build: cargo build --release -p standalone --features spout, v<version>, rustc <toolchain>, x86_64-pc-windows-msvcPast 15,099,494 B — 90 % of the cap — it warns. The figure the cap was derived from, 10,971,648 B, is a default-features build; the recipe builds with
spoutand the first tag build is what prints that reading.
Every one of those warns and never dies: a release blocked on a byte count is one where someone edits the constant under time pressure at a tag, which is worse than no gate because it also destroys the record.
What is still yours is the row. If a printed figure has moved more than ~100 KB since the last one, add a dated row to its series and say what moved it — the component’s in C ABI contract, the exe’s in Non-functional requirements §4. Those tables are the only record of the trend, and a trend is what a cap is actually about. The reminder sits here rather than only beside each table because the trigger it replaces — “re-measure when a dependency is added” — was conditioned on an event that never happened, and the growth arrived anyway.
What this does NOT touch
- The C ABI version (
RLX_ABI_VERSION,core-cabi/src/lib.rs) is a separate axis (ADR-0003). It moves only when theextern "C"surface changes shape — never on an app bump, and an ABI bump never implies an app bump. - Dependency versions (exact
=pins, cargo-deny) are unrelated. - The foobar plugin’s build.
cargo-releasedoes not run it — but since ADR-0025 the component version is no longer independent:plugin-foobar/build.ps1reads[workspace.package].versionout of rootCargo.tomland generatesbuild/foo_ritmolux_version.h, whichDECLARE_COMPONENT_VERSIONconsumes. So a bump here reaches foobar’s Components list on the plugin’s next build, with no second string to edit. (This revises ADR-0005’s original “independent plugin version” note.) Since Plan 0102 that next build is the tag push, not a developer runningbuild.ps1— and the packaging recipe fails the release if the DLL does not carry the workspace version, so the two cannot drift silently. - The pinned foobar2000 SDK (
packaging/foobar/sdk-pin.ps1) is a separate axis and never moves on an app bump. Moving it is its own commit, and it owes the on-device check in On-device validation — nothing in CI can load foobar2000.
Where the version surfaces
-
The standalone window title (
env!("CARGO_PKG_VERSION"), resolves to the workspace version). -
The
vX.Y.Zgit tag and every release-zip name (NFR section 8). -
The macOS bundle’s
CFBundleShortVersionString/CFBundleVersion, substituted intopackaging/macos/Info.plist.inat package time.bundle.shasserts the plist and[workspace.package]agree, so a drift fails the build rather than shipping. -
The foobar component’s
DECLARE_COMPONENT_VERSION, via the generatedbuild/foo_ritmolux_version.h(ADR-0025).build-component.ps1reads the version back out of the linked DLL and asserts it matches[workspace.package], so — as with the macOS plist — a drift fails the build rather than shipping. -
The studio’s
hellocheck:EXPECTED_PLAYER_VERSIONinstudio/shared/protocol.ts, andstudio/package.json’sversion. These two are edited by hand at every bump (above) and held to[workspace.package]bystudio/shared/version.test.ts.
Every other surface reads the one string in root Cargo.toml and is never edited by hand.
Built from 13c7582 at version 0.158.0. This site tracks main and is not versioned per release.