This document defines the official release workflow for this repository.
- Keep releases predictable and safe.
- Keep rapid prototyping separate from stable releases.
- Preserve cross-platform compatibility contracts already present in this repo.
- Make upgrades easy for users and maintainers.
feat/*,fix/*,docs/*,chore/*: short-lived working branches.staging: integration branch for fast testing on deployment platforms and bundled feature validation.main: stable, release-grade branch used for published releases and production-ready templates.
Default flow:
- Build the change on a short-lived branch.
- Merge it into
stagingfor integration testing. - Batch validated
stagingchanges into a release branch. - Merge the release branch into
main.
This project uses Semantic Versioning: MAJOR.MINOR.PATCH.
PATCH(x.y.Z): bug fixes, docs fixes, non-breaking maintenance.MINOR(x.Y.z): new features/services that are backward compatible.MAJOR(X.y.z): breaking changes requiring user action.
Treat these as stable API unless intentionally released as a major version:
- Dockerfile paths used by deploy templates (
apps/<app>/Dockerfile,apps/<app>/Dockerfile.<service>). - App package service names and generated runtime projections.
- Package manifest setup fields and env var contracts.
- Persistent volume semantics/locations.
- App URL patterns/subdomain expectations.
If any of the above must change:
- Add migration notes in
CHANGELOG.md. - Add upgrade instructions in the GitHub Release notes.
- Bump
MAJORversion.
Each release includes:
- A git tag:
vX.Y.Z - A GitHub Release using the same tag
- An updated
CHANGELOG.md - An updated root
VERSIONfile containingX.Y.Z - An updated root
releases/stable.jsonmanifest containing the stable channel metadata - A flashable disk image and its SHA256, hosted on
downloads.myownsuite.org
These files must agree with each other and with the release tag. The pipeline writes the
image and the release notes; the rest comes from npm run release:prepare.
The MOS1 layout also shipped an apps/suite-manager/release.json so packaged installs could report their version without the repo root. The MOS root layout reports the installed version from the root VERSION file, which stable release-track managed updates also use for installed-versus-latest comparison. Add a packaged metadata file back (and extend scripts/release-check.cjs) only if a Suite Manager distribution without the repo root returns.
Install local git hooks once per clone:
npm run hooks:installThese hooks block committing/pushing directly on main and reinforce PR-only workflow.
GitHub enforces the same thing on its side, so a fresh clone without hooks is still safe:
mainis protected — pull request required, CI must pass, no force pushes, no deletion. Admins are not enforced, so there is a deliberate manual override for emergencies.- The repository allows merge commits only. Squash and rebase merging are disabled, because a squashed release PR would rewrite the commit the tag is supposed to point at.
Use at least one of:
breakingfeaturefixdocschore
Version bump guidance:
- Any
breakingPR in release scope ->MAJOR - Else if any
featurePR ->MINOR - Else ->
PATCH
One command and a tag. Everything that can be checked is checked by one gate, and everything that can be automated happens when the tag is pushed.
-
Ensure
stagingis green (CI passing), contains the batch you want to release, and has nothing uncommitted. -
From
staging, prepare the release:npm run release:prepare -- X.Y.Z
This creates and switches to
release/vX.Y.Z, rewritesVERSIONandreleases/stable.json, moves everything under## [Unreleased]into a dated## [X.Y.Z]section, and then runs the release gate against what it just wrote. It refuses to leave a prepared-but-invalid tree behind, and it refuses to start from a dirty working tree so that unrelated work cannot ride along in the release commit. It does not commit, so review the diff. -
Commit the prepared files, open the PR into
main, and merge it with a merge commit. -
Optional, and worth it when the image build or the pipeline itself changed: go to Actions → Release → Run workflow on
main. That runs the same gate and the same image build, uploads todry-run/in the bucket, and stops before publishing. It rehearses the part of a release a moved tag cannot undo. -
Tag and push:
git tag vX.Y.Z git push origin vX.Y.Z
Pushing the tag runs .github/workflows/release.yml, which:
- refuses to go further if the tagged commit is not on
main, so a tag cannot publish code that never went through a pull request; - re-runs
npm testandnpm run release:check -- --release vX.Y.Z, so a hand-made tag cannot skip a check the prepare step would have caught; - renders the bake seed pinned to the tag, and fails if it resolves to a branch or carries a build-time password;
- bakes the disk image, boots the compressed artifact it is about to upload and refuses to continue unless Suite Manager answers on it, then uploads it to R2;
- publishes the GitHub Release with the download link, the SHA256, and the changelog section for that version.
Any failing step means no release is published. Fix forward and move the tag.
npm run release:check runs in two modes:
- No arguments — part of
npm teston every branch. Checks only that the release metadata agrees with itself, so ordinary work is never blocked by a changelog section nobody has written yet. --release vX.Y.Z— the gate a published release must pass. Every warning becomes a failure, and it additionally checks that the tag matchesVERSION, thatreleases/stable.jsonpoints at the right notes URL and carries a valid timestamp, and that nothing is stranded under## [Unreleased].
Both release:prepare and the pipeline run the second form. That is deliberate: there is
one definition of "ready to release" and no way for local and CI to disagree about it.
Only three things are yours:
- The version number. SemVer, per the rules above. No script can judge this.
- Whether the changelog reads like release notes. The gate checks entries exist, not that they are worth reading.
- When to tag. Everything after that is automatic.
Everything else is enforced. Do not hand-edit VERSION, releases/stable.json, or the
changelog headings as part of a release — release:prepare owns those files, and editing
them yourself is how they drift apart. Write changelog entries under ## [Unreleased]
as you work, and let the prepare step move them.
Nothing is published unless every job passed, so a failed pipeline leaves only a tag.
git tag -d vX.Y.Z # local
git push origin :refs/tags/vX.Y.Z # remoteFix the problem on a branch, merge it to main through a PR, then tag the new merge
commit. Never move a tag onto a different commit while it is still pushed — delete it
first, so nobody ever holds two different builds calling themselves the same version.
If the pipeline failed after the upload but before publishing, the R2 object for that version already exists. Re-running writes over it, which is fine: same version, same source commit, same bytes.
If a release was published and is wrong, publish X.Y.Z+1. Do not delete or rewrite a
published release — someone may already be running it, and releases/stable.json is what
installed servers compare themselves against.
Use for urgent production-impacting issues.
- Branch from released tag
vX.Y.Z:hotfix/vX.Y.(Z+1)
- Apply minimal fix.
- Update changelog with hotfix entry.
- Tag and release
vX.Y.(Z+1). - Merge hotfix back into
main.
Every release publishes a flashable disk image. There is nothing to do per release — this section is here so the wiring is understood, not operated.
What it is. Not an installer: a machine that was installed inside a VM by the pipeline and snapshotted, so the partition table and bootloader are decided at build time rather than on a stranger's hardware. It self-installs when booted from removable media. The installer ISO still exists and is still built — the bake runs it to produce the machine it snapshots — but it is no longer uploaded, linked, or documented as a download.
Where it lives. Cloudflare R2 bucket mos-downloads, served publicly through the bound
custom domain downloads.myownsuite.org:
| Path | Written by | Meaning |
|---|---|---|
vX.Y.Z/my-own-suite-vX.Y.Z.img.xz |
tag push | the published download |
vX.Y.Z/SHA256SUMS |
tag push | checksums beside the bytes, for the compressed and raw image |
dry-run/… |
manual Run workflow | rehearsal only, never linked; deleted by the next release |
The checksum is also attached to the GitHub Release, and that is the copy to trust: one served from the same host as the image it describes proves only that the host agrees with itself.
Why R2 and not a release asset. The image is ~2 GiB compressed, which is already at GitHub's 2 GiB per-asset cap and free to grow past it as the baked container images do. R2 has no egress charge, and the account already exists for the site.
Why the S3 API and not wrangler. wrangler r2 object put sends one request; a file
this size needs multipart upload. The workflow uses aws s3 cp, which R2 speaks natively.
Credentials. R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY are repository secrets — R2's
S3 credentials, from an Object Read & Write token scoped to this bucket. They are not
CLOUDFLARE_API_TOKEN, which belongs to the site deployment. CLOUDFLARE_ACCOUNT_ID is
shared with the site deployment and forms the S3 endpoint.
Storage. The free tier is 10 GB. Publishing a release deletes everything except the
newest two versions and the dry-run/ scratch prefix, and rewrites the notes of any release
whose image it removed so the page points at the current download instead of a 404.
Only ever install from the newest image: a server updates itself afterwards, so an old image installs the same machine by a slower route. The second is kept so that a withdrawal has somewhere to point.
A withdrawn image is also the only way to stop handing out a build with a known flaw. If a published image has to go before the next release prunes it, delete its prefix in the Cloudflare dashboard and edit that release's notes by hand — the pipeline owns the routine case, not the urgent one.
One build is flashed by everyone who downloads it, so anything decided while the image is built is shared by every machine installed from it, and extractable by anyone who has the file. The pipeline checks the seed before the multi-gigabyte build even starts, and every one of these failures is release-stopping:
- the seed must pin the tag, never a branch — otherwise the image installs whatever that branch happens to be later;
- the seed must carry no password. The installed machine generates its own console login on first boot and hands it over once through Suite Manager;
- the seed must be built with the release profile, not the debug one;
- the seed must not enable the lab reset agent, which is an unauthenticated endpoint that wipes the suite.
Setting LINUX_PASSWORD, or baking with -DebugBake, still pins a password for a lab
machine you build yourself. An image built that way must never be shared.
And it must boot. After the bake, the pipeline boots the compressed artifact it is about
to upload — on a disk deliberately larger than the image, because nobody installs onto a disk
the exact size of the download — and refuses to upload unless Suite Manager answers 200 and
the disk left behind passes image-builder/check-target.sh. So a boot-layout regression
cannot reach a download.
Almost everything that was once on a checklist is now enforced — the gate fails if it is not true. What is left is the judgement a script cannot make, plus the one check that needs a human with a USB stick.
Before tagging:
- Version chosen using the SemVer rules above
- Changelog entries describe outcomes an updating operator would want to read, not commits
- Breaking changes carry migration notes
- Honesty pages re-verified: rating-coverage wording, video links, and site screenshots still match the current product and UI
-
npm run release:prepare -- X.Y.Zpassed and the diff was reviewed - Release branch merged into
main
After tagging:
- Release page shows the download link, the SHA256, and that version's changelog section
- Image downloaded from the published link and its checksum matches the one on the release page
- Image flashed to a USB stick and self-installed onto real hardware at least once, reaching a working Suite Manager
The last item is the part of a release nothing can verify for you, and the reason is specific: the pipeline boots the published image in a VM, but neither QEMU nor Hyper-V presents a disk as removable, so the self-install guard — the thing that decides whether this is an install or an already-installed machine — is the one step no automated run exercises. Everything up to it is proven before upload; that step is proven by a person.