Making a release#
A release is a git tag. Pushing v1.2.3 starts both workflows: one builds and packages the
agent and the UI, the other builds the OS image — and only the second one publishes,
after it has installed that image and booted the disk. Neither job writes the checksum file
itself; a helper rescans the whole tag directory every time, so whatever landed there is
covered and a re-run leaves a complete, correctly signed set. One version number covers the
whole product, and the release notes are that version's section of the changelog rather
than a sentence that never changes.
What a version number covers#
One number for the agent, the web UI, the installer, the packaging and the OS image. They
are built and released together from one tag —
CHANGELOG.md. The format is
Keep a Changelog and the versioning is
Semantic Versioning.
Versions are written bare in prose — 0.5.1, not v0.5.1 — and with the v only when naming
a git tag or a release directory.
Before you tag#
- The changelog section exists. Move the work out of
## [Unreleased]into## [<version>] - <date>. Both jobs read that section as the release body, and a missing one publishes the literal textNo CHANGELOG.md section for <version>. main.versionmatches. The linker stamps-X main.versionfrom the tag; the constant inagent/main.gois what a non-tagged build reports, so keeping it in step is part of the release, not an afterthought. The UI has no such constant to keep in step — it reads theproductblock fromGET /api/systemfirst andVITE_WHEELHOUSE_VERSIONsecond, and reportsunknownwhen it has neither.- The pipeline is green on
main. Everything on The test suites has run. - It has been driven in a browser against a live router. That is the definition of
done in
AGENTS.md, and no pipeline can do it for you.
git tag -a v1.2.3 -m "Wheelhouse OS 1.2.3"
git push origin v1.2.3What the tag sets off#
Two workflows run in parallel, and only one of them publishes.
ci.yml, after the tests pass#
- Builds the agent for
amd64andarm64. - Packages a
.debper architecture, plus a tarball per architecture, and copiesCHANGELOG.mdin beside them — the download host is a file server behind the licence key, with no release page, so the notes have to be a file. This job publishes none of it. What a release directory carries is decided iniso.yml, after the gate. - Writes a
SHA256SUMSover its owndist/, and signs it if a key is configured. - Keeps the whole of
dist/as a run artifact, and stops. It creates no release and writes nothing to the mirror: the packages have not yet been part of an image that somebody installed.
iso.yml, in about 30 to 60 minutes#
- Builds
ui/distand the agent for both architectures, and packages the.debs and tarballs from its own checkout — a job token cannot read another private repository's releases. - Runs
build-iso.sh. - Inspects the finished image and writes the list of files the release will carry (Building the image), failing by name if one is missing.
- Installs the image.
tools/release-readiness.shrunsinstall-smoke.sh: install from a seed, take the medium away, boot the disk, and check the machine that comes back. Nothing below runs if this fails. - Creates the forge release with the changelog body and attaches the named set the
inspection step wrote: the image, its checksum,
CHANGELOG.md, the corresponding-source record, the build-tooling patch, the build-source tarball, the package inventory, and the agent.debfor every architecture the packages step built. - Runs
publish.sh <tag> /srv/wheelhouse-releases …once, with everything. - Reads the mirror back with
release-index.py --check-only.
publish.sh, in order#
.forgejo/workflows/publish.sh is what iso.yml
calls, once per tag, with the packages and the image together. It does six things:
- Copies the named files into
<releases-root>/<tag>/, after checking each is really a file — an unmatched glob arrives as its own literal, andcpwould otherwise report a missing file whose name looks like a pattern. - Deletes any signature already there.
SHA256SUMSis about to be replaced, and a signature that no longer matches what it covers is worse than no signature. - Runs
release-index.py --link-latest, which rewritesSHA256SUMSover every artefact in the tag directory, moveslatest, and writes the channel index. - Makes everything world-readable — after the checksums exist, not before, or the one file the release notes tell customers to fetch inherits the runner's umask.
- Signs
SHA256SUMSthroughinstall/sign.sh, whenRELEASE_SIGNING_KEYis set. - Re-runs
release-index.py --index-only, so the channel index recordssigned: true.--index-onlyreads the digests back out ofSHA256SUMSinstead of re-hashing: rewriting a file that has just been signed is exactly how a release acquires a signature that no longer matches what it covers.
release-index.py#
.forgejo/workflows/release-index.py writes
two things, with the standard library and no pip.
<tag>/SHA256SUMS#
One file over every artefact in the tag directory — the image, the agent .debs, the
changelog, the corresponding-source record and everything else that landed there. It skips
itself, its own signature, the per-file .sha256 sidecars and the channel JSON, and it
writes through a temporary file and an atomic replace. It is a rescan, not a list: a file
copied into the directory is covered because it is there, which is why the .deb needed no
special case to be checksummed and none to appear in the index (kind: "deb", with the
architecture read out of its name).
<channel>.json#
The channel is derived from the tag: a plain vX.Y.Z is stable, anything with a
pre-release suffix (v1.0.0-rc1) is beta.
{
"channel": "stable",
"version": "0.5.1",
"tag": "v0.5.1",
"base": "https://releases.rhymelikedi.me/v0.5.1/",
"notes": "…/CHANGELOG.md",
"checksums": "SHA256SUMS",
"signature": "SHA256SUMS.asc",
"signed": false,
"artifacts": [
{"name": "wheelhouse-0.5.1-amd64.iso", "kind": "iso", "arch": "amd64",
"size": 512483328, "sha256": "…"}
]
}version is the comparison key, base plus an artefact name forms the download URL,
sha256 is what to check, and signed says whether signature is real. Fields may be
added; a consumer must ignore what it does not know.
latest#
--link-latest points <root>/latest at the tag, through a temporary symlink and an
atomic os.replace, so a reader never sees latest missing.
It follows the stable channel, not the newest tag. Publishing a -rc or +build tag
prints latest follows the stable channel and <tag> is a beta release; leaving it alone
and returns, so long as a stable.json already exists; only while no stable release has
ever been published does latest fall back to the newest tag of any kind. --force
overrides that. Tell a beta tester to watch beta.json or the tag's own directory —
/latest/ will never hand them a release candidate.
It also refuses to move backwards: re-running the pipeline for an old tag, which is a
normal thing to do after fixing a publish, would otherwise hand every customer last
month's image. The one exception is that a real release always supersedes a pre-release,
so the first stable tag takes latest off the release candidate that was standing in for
it.
The release notes#
release-notes.py prints the body: the
version's changelog section, framed by a link to the tag's directory and the verification
instructions. It exists because the same twenty lines were inlined in two workflow files,
and a pair like that drifts. It lives beside the workflows because Forgejo only treats
*.yml in that directory as workflows, so a helper next to them is checked out with them.
Signing#
The verification half already works and needs no key material of ours:
curl -fsSLO https://releases.rhymelikedi.me/latest/SHA256SUMS
curl -fsSLO https://releases.rhymelikedi.me/latest/wheelhouse-<version>-amd64.iso
sha256sum --ignore-missing -c SHA256SUMS
# Once a key exists, from a directory holding the whole tag:
WHEELHOUSE_PUBKEY=./wheelhouse-release.asc bash install/sign.sh verify SHA256SUMSsign.sh verify imports the public key into a throwaway keyring, so verifying a download
never touches the caller's own. REQUIRE_SIGNATURE=1 makes it fail when nothing is
signed, which is what a script should set. It checks every line of SHA256SUMS and
has no --ignore-missing, so after a partial download it stops at checksums do not
match — which is not what happened. A customer who fetched one image checks the sums with
--ignore-missing and the signature on its own;
Download and verify has those commands.
Where a release ends up#
| Place | Needs a login? | Carries |
|---|---|---|
The forge release at git.rhymelikedi.me | Yes — the repository is private | Every artefact and SHA256SUMS |
https://releases.rhymelikedi.me/<tag>/ | Yes — a paid-up licence key | The same files, the agent .deb among them |
https://releases.rhymelikedi.me/latest/ | Yes — the same key | A symlink following the newest stable tag — a beta tag does not move it |
https://releases.rhymelikedi.me/stable.json | No | The channel a customer can be told to watch |
The gate is Caddy's forward_auth to the licence server in front of the whole directory,
and only stable.json and beta.json are served without a key. Publishing the .deb
there needed no change to it: a file in a gated directory is gated.
If a publish goes wrong#
Re-running the tag's workflow is safe by design: publish.sh deletes the stale signature
before rewriting the checksums, release-index.py rescans the whole directory rather than
appending, and latest will not move backwards onto an older tag. What it does not do
is remove an artefact that should not have been published — that is a manual deletion on
the download host, followed by another run so the checksums and the index stop naming it.
See also#
- What CI does — every step of both workflows.
- Building the package · Building the image — what is being published.
- The test suites — what has to be green first.
- Working agreements — the definition of done behind a tag.
- Download and verify — the customer's side
of
SHA256SUMS, and why a checksum is not a signature. - Release history — the published record of what each tag changed.
- Channel index format — every field of
stable.json, enumerated. - Watching for releases — the channel index, from the operator's side.
- Upgrading the image — what a customer does with the result.