Wheelhouse docs

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#

  1. 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 text No CHANGELOG.md section for <version>.
  2. main.version matches. The linker stamps -X main.version from the tag; the constant in agent/main.go is 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 the product block from GET /api/system first and VITE_WHEELHOUSE_VERSION second, and reports unknown when it has neither.
  3. The pipeline is green on main. Everything on The test suites has run.
  4. 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.
bash
git tag -a v1.2.3 -m "Wheelhouse OS 1.2.3"
git push origin v1.2.3

What the tag sets off#

Two workflows run in parallel, and only one of them publishes.

ci.yml, after the tests pass#

  1. Builds the agent for amd64 and arm64.
  2. Packages a .deb per architecture, plus a tarball per architecture, and copies CHANGELOG.md in 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 in iso.yml, after the gate.
  3. Writes a SHA256SUMS over its own dist/, and signs it if a key is configured.
  4. 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#

  1. Builds ui/dist and 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.
  2. Runs build-iso.sh.
  3. Inspects the finished image and writes the list of files the release will carry (Building the image), failing by name if one is missing.
  4. Installs the image. tools/release-readiness.sh runs install-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.
  5. 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 .deb for every architecture the packages step built.
  6. Runs publish.sh <tag> /srv/wheelhouse-releases … once, with everything.
  7. 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:

  1. Copies the named files into <releases-root>/<tag>/, after checking each is really a file — an unmatched glob arrives as its own literal, and cp would otherwise report a missing file whose name looks like a pattern.
  2. Deletes any signature already there. SHA256SUMS is about to be replaced, and a signature that no longer matches what it covers is worse than no signature.
  3. Runs release-index.py --link-latest, which rewrites SHA256SUMS over every artefact in the tag directory, moves latest, and writes the channel index.
  4. 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.
  5. Signs SHA256SUMS through install/sign.sh, when RELEASE_SIGNING_KEY is set.
  6. Re-runs release-index.py --index-only, so the channel index records signed: true. --index-only reads the digests back out of SHA256SUMS instead 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.

json
{
  "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:

bash
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 SHA256SUMS

sign.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#

PlaceNeeds a login?Carries
The forge release at git.rhymelikedi.meYes — the repository is privateEvery artefact and SHA256SUMS
https://releases.rhymelikedi.me/<tag>/Yes — a paid-up licence keyThe same files, the agent .deb among them
https://releases.rhymelikedi.me/latest/Yes — the same keyA symlink following the newest stable tag — a beta tag does not move it
https://releases.rhymelikedi.me/stable.jsonNoThe 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#

Updated 2026-09-11 development release versioning signing