# Releasing For a normal release, run **Actions → Prepare next release → Run workflow** and choose a patch, minor, or custom version. It opens a tiny version-bump PR; merging that PR automatically starts **Release** and assembles a draft from the exact merge commit. Review and publish the draft when it is ready. The existing **Actions → Release → Run workflow** button remains available for reruns and recovery. It builds macOS (arm64 + x64, signed, notarized, stapled), Windows, and Ubuntu from a single pinned commit, verifies every artifact the way a user would receive it, and assembles the canonical draft in [OpenMausBot releases](https://github.com/milind-soni/OpenMausBot/releases). The exact same assets are also staged in the public legacy releases repo so installed builds from 0.1.46 and earlier can update across the repository migration. Tick **publish** to publish and verify the canonical release first, then make the identical legacy updater bridge visible. Leave it unticked to review the canonical draft; when you publish that draft in GitHub's UI, the **Sync published release** workflow verifies and publishes its legacy mirror automatically. Never publish only the legacy draft. The release commit must have passed CI on every platform: the **CI passed on the release commit** job waits (while the platforms build) for a ci.yml run whose `CI` check passed with every Linux, Windows and macOS shard, on the pinned commit or a commit with the same tree, and stops the release before any draft if the commit's CI failed. A push to main runs the Linux shards only, so unless the release branch's own CI (started by **Prepare next release**) or a nightly run covered the same tree, the job runs the full CI on the commit itself on a temporary `release-ci/v` branch, which takes about as long as the platform builds. After a flaky failure, re-run the failed CI jobs; a release still waiting picks up the new attempt, and a release that already stopped needs only `gh run rerun --failed`. `ship_without_ci` skips the wait, for emergencies only. See [CI job selection](ci.md#main-and-releases). The workflow refuses to overwrite an already-published version. Manual Release runs still require `package.json`'s version to be bumped on the selected ref. A release is rejected if any installer, stable download name, updater feed, blockmap, size, or digest is absent or inconsistent. GitHub generates the release body from pull requests since the previous canonical tag. The docs changelog combines published canonical releases with the legacy archive into one complete history and caches it for five minutes. Configure the optional Vercel hook below to rebuild the docs immediately after publication; otherwise the live cache or the next normal docs deployment refreshes it. ## Data format and rollback Each release records the data-folder formats it writes and reads: **Prepare next release** runs `scripts/record-data-format.mjs`, which writes `server/data-format-released.json` into the version-bump commit. The next release's changes are tested against that record, so a version never writes data the release before it cannot read ([CONTRIBUTING.md, "Changing how data is stored"](../CONTRIBUTING.md#changing-how-data-is-stored)). A version bump made by hand needs the same command, or `server/data-format.test.ts` fails on the mismatched version. ## Promote a release to stable Self-hosted servers do not follow the newest release: they follow `stable`. A release becomes stable only after our own Clouds have run it for 48 hours. Then run **Actions → Promote to stable → Run workflow** with the version (`X.Y.Z`) and tick that the 48 hours are done. The workflow builds nothing. It: 1. refuses a version that is not a published, full release, or that was published less than 48 hours ago (`urgent` skips the wait, for a fix that cannot wait); 2. checks that the release's server image has `linux/amd64` and `linux/arm64`, and reads its digest and the Cloud home image's; 3. refuses to move stable back to an older version unless `allow_older` is ticked; 4. moves the `stable` tag of `ghcr.io/milind-soni/openmausbot` and of `ghcr.io/milind-soni/openmausbot-cloud-home` to those digests; 5. commits `stable.json` (version, both images by digest, and every version that was stable in the last 90 days) to the `channels` branch. When that fails after the tags moved, the tags are put back to the digests the published record still names. It reads the current record before it writes the next one. Only a `channels` branch that does not exist counts as "no record yet"; any other failure to read it stops the run, so a network error never skips the move-back check or drops the history. Servers read `https://raw.githubusercontent.com/milind-soni/OpenMausBot/channels/stable.json`: the update check (`server/update-check.ts`) for the version, and `deploy/update.sh` for the image to pull. `latest` still follows `main` and is never moved by this workflow. Running it again with the same version changes nothing, and repairs a run that stopped between the tag and the record. Until a first version is promoted, both fall back to the newest release. The `channels` branch is written only by this workflow; do not edit it by hand. The checks live in `scripts/stable-channel.mjs`. ### How a server takes an update One routine, in the two places that can run it. A server cannot replace its own container, so neither is the server process: a connected server's update is the connector's `update` job, and a standalone Docker Compose install's is `deploy/update.sh`, run on the host by its owner. They are two programs (the connector drives a container it made itself through the Docker API and has no shell; a Compose install has to be swapped by `docker compose` on the host), held to the same steps and the same promises: | Step | Connector (`connector/src/home.ts`) | Standalone (`deploy/update.sh`) | | --- | --- | --- | | Image by digest, before anything stops | `ensureImage`, `checkImage` | `docker pull` of the record's `…@sha256:…` | | Room for the copy | `ensureRoomForCopy` | `room_for_copy` | | Stop, copy `/data` aside | `stopAndCopy` → volume `mausbot-snapshot` | `stop_server` (checked stopped), `copy_aside` → a new volume `_data-before-update-