Cut an Anthias release
Anthias versions are CalVer YYYY.0M.MICRO (zero-padded month). This is the
actionable release runbook; the reference facts behind each step live in the
anthias-release skill.
Sequencing is load-bearing. Publishing the GitHub release triggers the balena disk-image + OTA pipeline, whose preflight verifies the per-board container images already exist in GHCR. Those are built by the master-push Docker build. So you must merge the bump, wait for the full master Docker build to go green across every board, and only then publish the release. Publishing early fails preflight (a guard catches it — no harm — but the deploy jobs skip).
0. Pick the version
- CalVer
YYYY.0M.MICRO. Check the current month — a new month resets MICRO to0(the release after2026.05.2is2026.06.0, not2026.05.3). - Two spellings of the same number, and you need both:
- zero-padded
2026.06.0— forpackage.json,pyproject.toml, the git tag. - strict semver, no leading zero
2026.6.0— foruv.lock(SemVer §9; balena also requires this form, rendered from pyproject at deploy time).
- zero-padded
1. Bump version metadata (all relevant files)
The release bump touches exactly three files (confirmed against every prior
chore(release) commit). bun.lock carries no root version — do not touch it.
package.json→"version": "2026.06.0"(zero-padded)pyproject.toml→version = "2026.06.0"(zero-padded)uv.lock→ under thename = "anthias"package block,version = "2026.6.0"(strict semver, no leading zero). Bump this line directly, or regenerate withuv lockand confirm only the anthias version line changed.
Sanity check before committing — these must be the only version edits:
git diff -- package.json pyproject.toml uv.lock
grep -n '"version"' package.json; grep -n '^version' pyproject.toml
grep -n -A1 'name = "anthias"' uv.lock
2. PR → merge to master
- Branch, commit as
chore(release): bump version metadata to <ver>(the established convention), open a PR ready-for-review, let CI pass, merge tomaster. Admin can merge pastREVIEW_REQUIREDwithgh pr merge --squash --admin(enforce_admins=falseon master).
3. Wait for the master Docker build (ALL boards)
- The merge to
mastertriggersdocker-build.yaml, which builds and pushesghcr.io/screenly/anthias-{server,viewer,redis}:<short-hash>-<board>for every board. The 32-bit armhf (Pi 2 / Pi 3) jobs run under QEMU and finish last — wait for the whole matrix. - Track it:
gh run list --workflow=docker-build.yaml --branch master→ watch the run for the bump commit to conclude success. - Optional preflight (what the release pipeline checks): for each board,
docker buildx imagetools inspect ghcr.io/screenly/anthias-server:<short-hash>-<board>should resolve (repeat for viewer/redis; includearm64for Rock Pi 4).
4. Tag + create the GitHub release — auto-generated notes FIRST
Create the release as a draft with GitHub's auto-generated notes. Target the
full commit SHA (a short hash is rejected as target_commitish); draft
avoids triggering the deploy pipeline while you edit.
SHA=$(git rev-parse origin/master) # full 40-char SHA of the merged bump
gh release create v2026.06.0 \
--draft --generate-notes \
--title 2026.06.0 \
--target "$SHA"
--generate-notes populates the body with GitHub's PR-based changelog (the
"first round").
A draft release does NOT create the tag. GitHub defers tag creation until the
release is published, so after this command git ls-remote --tags upstream refs/tags/v2026.06.0 returns nothing and the release URL is a placeholder
(releases/tag/untagged-<hash>). That is a feature: nothing is outward-facing
yet, so if QA turns up a blocker you can delete the draft without burning a
version number or deleting a published tag. The tag appears — at the
--target SHA — at step 6.
Also note the board matrix is 7 boards (pi2, pi3, pi3-64, pi4-64, pi5,
x86, arm64); there is no pi1 or 32-bit pi4 image, so a preflight loop that
invents those tags will report false gaps. On the release-asset side only 4 Pi
boards get an rpi-imager entry and the 32-bit pi3 has no per-board .json (the
imager's "Raspberry Pi 3" entry uses the 64-bit image) — expected, not a gap.
5. Layer our curated notes on top (keep the auto-generated list)
Capture the generated notes, prepend a short human summary of the highlights, and update — do not discard the auto-generated changelog:
gh release view v2026.06.0 --json body -q .body > /tmp/notes.md
# Prepend a curated "## Highlights" section above the generated list,
# then write the combined file back:
gh release edit v2026.06.0 --notes-file /tmp/notes.md
Keep the curated summary short; link notable PRs/releases as markdown links.
5b. Burn-in on the testbeds — while the release is still a draft
The draft is the QA gate: nothing is outward-facing yet, so a blocker found here costs a deleted draft rather than a published tag and a fleet OTA. Run the soak per testbed-qa §6 (all supported asset types on every board, shipping configuration, playlist verified rotating), then report measured numbers.
Soak the newest <short-hash> that actually contains device code — a
website-only commit produces no images (docker-build.yaml excludes
website/**), and the version-bump commit itself only touches
package.json / pyproject.toml / uv.lock, so the preceding code commit is
code-identical to the release and is the correct target.
6. Publish → the deploy pipeline runs
gh release edit v2026.06.0 --draft=false --latest
Flipping to published (release: published, startsWith(tag,'v')) triggers
build-balena-disk-image.yaml:
preflightre-verifies every<short-hash>-<board>image exists in GHCR (this is why step 3 must be fully green first).balena-build-imagesbuilds bootable disk images for fresh flashes.balena-cloud-deployis the actual fleet OTA (independent of the disk images — OTA can be green while a disk-image job fails).
7. Verify / recover
- Watch the run:
gh run list --workflow=build-balena-disk-image.yaml. - Transient disk-image failures → rerun, don't debug.
balena os download --version latestintermittently dies withERR_STREAM_PREMATURE_CLOSE(a CDN blip);gh run rerun <run-id> --failedfixes it (also re-runs the skippedupload-release-assets+build-rpi-imager-json). - If preflight failed because you published too early, re-trigger after the
master build is green:
gh workflow run build-balena-disk-image.yaml -f tag=v2026.06.0.
Notes
- Deploying master to fleets WITHOUT a release is a different flow —
bin/balena_ota_deploy.sh <board> <ver> <short-hash>per board (see the anthias-release skill). That leaves no git tag / GH release / disk image; cutting the release later is a separate explicit step. - CI derives the version from the tag (
${TAG#v}); local deploy greps[project].versionfrompyproject.toml. They agree because CI checks out at the tag — so keep the tag and pyproject in lockstep.