Releasing
The repository follows git flow, versions are semantic, and every release is a version on main that CI tags v<version> and turns into installers and a server bundle on a GitHub Release.
Branches
| Branch | Role |
|---|---|
develop | The integration branch and the default branch: every feature lands here through a pull request. |
main | Released code only. Every commit on main is a release merge or a hotfix merge, and carries a tag. |
feature/* | Branched from develop, merged back into develop by pull request. |
release/x.y.z | Branched from develop by the release script; only version bumps and release fixes go here. |
hotfix/x.y.z | Branched from main by the release script for an urgent fix to a released version. |
CI (ci.yml, sast.yml) runs on pushes to main and develop and on every pull request. The docs site builds on main.
Versions
Semantic versioning, MAJOR.MINOR.PATCH, with pre-releases as -alpha.N, -beta.N, -rc.N. The proof of concept lives at 0.0.0-alpha.N: anything may change. The first minor (0.1.0) is the first version meant to be played by someone else.
All workspaces share one version (the root and every apps/* and packages/* manifest), and the workspace dependencies are pinned to it; the release script keeps them in step.
Cutting a release
npm run release -- start 0.2.0 # release/0.2.0 from origin's develop: versions bumped, version badge redrawn, committed
npm run validate # fix what needs fixing, commit on the branch
npm run release -- finish # merge into main, back-merge into develop, push bothstart fast-forwards develop (main for a hotfix) to origin before it branches, so a checkout that never pulled the last merged pull requests still releases them, and runs npm ci when that changed the lock file (the release commit's hooks type-check against node_modules); it fails, changing nothing, when the local branch has commits origin hasn't or is checked out in another worktree.
finish fast-forwards main and develop to origin, merges the release branch into main and main into develop, pushes both in one go (the pre-push hook runs validate:push once; the Release workflow verifies the full validate), then deletes the release branch. The push of main triggers the Release workflow:
version: the version inpackage.jsonmust be unreleased. Av<version>tag at another commit fails the run (something reachedmainwithout a bump); a tag at this very commit with its release makes the run a no-op, so re-running it is harmless.verify:npm run validate, on the larger runnerTESTS_RUNNERnames (ubuntu-4-core).e2e: the browser's two end-to-end tiers, the gate's and the long tests', on the self-hosted runner and GitHub's GPU runners (.github/workflows/e2e.yml, about 12 minutes; Quality gates).client:electron-builderon Windows, macOS and Linux producesfive-on-it-<version>-<os>-<arch>.exe|dmg|AppImage(unsigned for now).server:npm run pack:serverbundles the server with esbuild intofive-on-it-server-<version>.tgz: one dependency-freeserver.mjs(unpack,PORT=7777 node server.mjs). The same bundle ships inside the Electron app, which starts it when the player hosts on Play local.stack: the Compose stack's four images built under this release's names (ghcr.io/<owner>/five-on-it-<node|nakama|worker|proxy>:<version>), the stack started on them with throwaway secrets and the smoke run through its proxy from inside the stack (itssmokeservice, on the release's own node image; a stack that does not come up does not ship); the images pushed to GHCR when the repository variableFOI_PUSH_IMAGESistrue; andnpm run pack:stack'sfive-on-it-stack-<version>.tgz, what a host installs from (Deploying). About 10 minutes on a hosted runner.publish, once every job above passed: a GitHub Releasev<version>at the pushed commit with generated notes and the release's files attached (the installers, the server bundle, the stack bundle; not the end-to-end tiers' reports); creating it creates the tag, so a tag exists only once its release does. A pre-release when the version has a pre-release part. Then the mergedrelease/<version>orhotfix/<version>branch is deleted if it is still on origin.docs, once the release is published: its documentation built as the public site under its minor's folder and published to the docs site,/latest/moved to it (below). A pre-release has none; a failure leaves the release as it is.steam, never on its own: only on Run workflow withsteamticked, once the release is published, and only when a reviewer of thesteamenvironment approves it (below).
With the repository variable SELF_HOSTED_ONLY set (the self-hosted runner's label; Quality gates), every job runs on that runner: verify there, client replaced by client-self-hosted (the Linux installer, and the Windows one built in electron-builder's wine image; no macOS installer), publish after whichever of the two ran.
Watch it at https://github.com/ggcapo/five-on-it/actions/workflows/release.yml; Run workflow on main re-runs it by hand (a no-op once the version is released).
A release does not update the live platform. Once it is published, run Actions → Update the platform (a dry run first, then again with dry_run unticked) or npm run fleet:update -- --version <version> --yes: a backup, the release's own stack-server, each game host added again, then the check (Operations).
Two repository variables (Settings → Secrets and variables → Actions → Variables) shape a release:
FOI_PLATFORM_URL: the platform the desktop app plays online on (its API,https://play.example.com/api), baked into the installers as they are built. Unset, the app asks the player for one. Set since 2026-10-15 (from 0.63.0) tohttps://play.fiveonit.online/api, the live platform (Operations); a platform moved to another domain needs it changed and a release for the app to follow.FOI_PUSH_IMAGES:true(since 2026-10-11, from 0.42.1) pushes the stack's images to GHCR, linked to the repository by theirorg.opencontainers.image.sourcelabel. The packages of a private repository are private and count against the organization's Packages quota (500 MB of storage and 1 GB of transfer a month free; pushes from Actions and pulls inside Actions are not counted). A version is about 1.8 GB compressed (1.2 GB of it the worker's Blender layer, measured on 0.42.1's push), and a later one shares most of its layers (the base images, Blender) with the one before; a host's first pull is about 1.8 GB, its updates a few hundred MB. Delete the versions no host runs any more (the package's settings), and keep a Packages budget the organization accepts. Unset, the bundle is still made, and a host builds its images from a clone instead (npm run stack:up).
The documentation
The documentation site (the guides, the design documents, the folders' READMEs and the API reference) is published at the docs host, https://docs.<FOI_DOCS_HOST's domain> (the repository variable DOCS_URL), one version a minor, the way documentation sites usually are:
| Address | What |
|---|---|
/latest/<page> | The page in the latest release: a redirect to its minor's (/0.77/<page>), so a link to it never goes stale. / goes there. |
/0.77/<page> | A release's: its minor's latest patch (0.77.2 replaces 0.77.0's pages). The latest release and the ten minors before it stay; older ones are pruned. |
/next/<page> | develop's, published on every push of it: what the next release will be. Kept out of search engines. |
/versions.json | The index every version's switcher reads (so a version published long ago offers the ones after it); /live.json is the platform's /api/info, for the version it runs now. |
Every page has a version switcher in its nav bar (the latest, next, the older minors, the one the platform runs marked live; it opens the same page in the version picked, or that version's home when it has none) and, on next and on an older minor, a banner linking the same page in the latest release; Versions lists them all. Each version's pages name their /latest/ address as canonical, and the sitemap (/sitemap.xml, the latest's) lists those. The pages about running the live platform (Operations, deploy/fleet, the deployment plan) are left out of the published site (FOI_DOCS_PUBLIC=1); a link to one opens a page that says it is internal.
.github/workflows/docs.yml does it: on a push of develop (next), called by the Release workflow's docs job, or by hand (Run workflow with a release's tag, v0.77.2, to publish it again, or develop). Its build job builds the site under the version's folder (FOI_DOCS_BASE=/0.77/, FOI_DOCS_VERSION, FOI_DOCS_RELEASE, FOI_DOCS_URL: .vitepress/config.ts, the switcher in .vitepress/theme/), its deploy job, in the docs environment, sends it over SSH with the docs deploy key: the version swapped in whole, then the index worked out by build/docs-versions.mjs (written only over the one it read, so two publishes at once never lose each other's version), LATEST, robots.txt and the prunes. The key can run deploy/docs/docs-publish.sh on the host and nothing else; the docs site is the platform's server's proxy's second site (deploy/Caddyfile, serving /opt/foi/docs). Without DOCS_URL the site is built, which checks it, and not published.
Setting it up once (each step changes the live platform: an admin's):
- Make the docs deploy key:
ssh-keygen -t ed25519 -f foi-docs -N '' -C foi-docs. - In
deploy/fleet/.env.fleet:FOI_DOCS_HOST=docsandFOI_DOCS_DEPLOY_KEY=the contents offoi-docs.pub; then update the platform to a release that has the docs site (npm run fleet:update -- --version <version> --yes, orstack-serverfrom its tag). It pointsdocs.<domain>at the platform's server, gives its proxy the docs site, installs the script and lets the key run it. - In the repository's settings: the environment
docswith the secretsDOCS_SSH_KEY(the contents offoi-docs) andDOCS_KNOWN_HOSTS(ssh-keyscan -t ed25519 docs.<domain>, checked against the server's own key), and the variableDOCS_URL(https://docs.<domain>). Set theplatformenvironment'sFLEET_ENVagain, since.env.fleetchanged. - Run workflow on Docs with the latest release's tag, then with
develop. From then on every release and every push ofdeveloppublishes itself.
Releases before the docs site have no versioned documentation: their tags have neither the switcher nor the version's folder.
Steam
The game is sold on Steam (Platform). Its builds there are the desktop app built with the game's app id baked in (FOI_STEAM_APP_ID, electron/steam.ts): such a build starts Steam's API, asks Steam to start it when it was launched outside Steam, turns the overlay on, and signs players in with their Steam ticket. The GitHub Release's installers carry no app id and never touch Steam.
| What | Where |
|---|---|
| The app id | The repository variable FOI_STEAM_APP_ID (the API's FOI_STEAM_APP_ID too, for FOI_STEAM=web). |
| The depots | One per platform, the repository variables STEAM_DEPOT_WINDOWS, STEAM_DEPOT_LINUX, STEAM_DEPOT_MAC; each holds that platform's unpacked app (win-unpacked, linux-unpacked, Five On It.app). |
| The branch | The beta branch a build goes live on, STEAM_BRANCH (beta by default). Never default: promoting a build to the default branch is done by hand on the Steamworks partner site, after a look at the beta. |
| The build account | The secrets STEAM_BUILD_USER (a Steam account with only the build rights, Steam Guard on) and STEAM_CONFIG_VDF: steamcmd's config/config.vdf in base64, from signing that account in once by hand (steamcmd +login <user>, its password and Steam Guard code), so CI never needs either. |
| The manual approval | The steam environment (Settings → Environments) with required reviewers; its secrets can live there. |
Uploading a release. Run workflow on main with steam ticked. The steam job waits for the version's release (a version released before goes at once), then for a reviewer of the steam environment, then builds the app for Steam unpacked for Linux and for Windows (in electron-builder's wine image), signs steamcmd in with the kept login (its folder and home its own, in the run's temp folder: a self-hosted runner's own Steam is never touched) and runs node build/steam-build.mjs --upload, which writes the build files and runs run_app_build. Without the variables and the secrets it says which are missing and stops there. GitHub's runner gets steamcmd's 32-bit library itself; a self-hosted one must have it (lib32gcc-s1).
macOS is not built by the job (it is built and notarised on a Mac only): on a Mac, FOI_STEAM_APP_ID=<id> npm run -w apps/client build, then npx electron-builder --mac dir in apps/client with the signing and notarisation settings, then FOI_STEAM_APP_ID=<id> STEAM_DEPOT_MAC=<depot> STEAM_BUILD_USER=<user> node build/steam-build.mjs --mac "apps/client/release/mac-arm64/Five On It.app" --upload. The bundle carries libsteam_api.dylib beside steamworks.js's module (unpacked from the asar), which the signing covers.
Before a build goes past the beta branch, try it by hand from Steam's beta branch on Windows, on macOS (the notarised build, its dylib loading) and on Linux or a Steam Deck: the game starts from Steam (and, started outside it, Steam starts it), the overlay opens (Shift+Tab), Play online signs in with the Steam ticket and plays, and with Steam closed the app opens, Play local hosts, and Play online offers Steam's page in a window.
node build/steam-build.mjs without --upload is a dry run anywhere: it writes the build files into dist/steam and prints them.
Hotfixes
git checkout main && git pull
npm run release -- hotfix 0.2.1 # hotfix/0.2.1 from main, versions bumped
# fix, commit, validate
npm run release -- finish # merge into main, back-merge into develop, push bothFrom a Claude Code cloud session
A claude --cloud session pushes through a credential of its own, not the developer's account: it may update branches (that is how it delivers pull requests) but neither create a tag nor delete a reference, and GitHub refuses those with a bare 403, no repository rule involved. The process above asks neither of the session: finish pushes main and develop, and the workflow tags and deletes the release branch (finish tries first and says so when it cannot). So a session cuts a release exactly like a laptop does: merge the pull request into develop, start, validate, finish, and watch the workflow.
Rules
- Never tag by hand and never push to
maindirectly;finishis the only way a version reachesmain, the workflow is the only thing that tags, and it refuses a version released before. - A release branch takes fixes, not features. If a feature is missing, finish the release and start the next one from
develop. npm run pack:serverandnpm run -w apps/client packagework on a laptop too, to try a deployable build before releasing.