| name | cut-release |
| description | Step-by-step runbook for cutting a tagged Anthias release — bump the CalVer version across all relevant manifests/lockfiles, merge, wait for the master Docker build, tag, and publish a GitHub release (auto-generated notes first, then layered with a curated summary). Use when the task is to ship a new versioned release. For the underlying "why" (balena topology, OTA-only deploys, CI internals) see the anthias-release skill. |
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
to 0 (the release after 2026.05.2 is 2026.06.0, not 2026.05.3).
- Two spellings of the same number, and you need both:
- zero-padded
2026.06.0 — for package.json, pyproject.toml, the git tag.
- strict semver, no leading zero
2026.6.0 — for uv.lock (SemVer §9;
balena also requires this form, rendered from pyproject at deploy time).
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 the name = "anthias" package block, version = "2026.6.0"
(strict semver, no leading zero). Bump this line directly, or regenerate
with uv lock and 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 to
master. Admin can merge past REVIEW_REQUIRED with gh pr merge --squash --admin (enforce_admins=false on master).
3. Wait for the master Docker build (ALL boards)
- The merge to
master triggers docker-build.yaml, which builds and pushes
ghcr.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; include arm64 for 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)
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
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:
preflight re-verifies every <short-hash>-<board> image exists in GHCR
(this is why step 3 must be fully green first).
balena-build-images builds bootable disk images for fresh flashes.
balena-cloud-deploy is 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 latest intermittently dies with ERR_STREAM_PREMATURE_CLOSE (a CDN
blip); gh run rerun <run-id> --failed fixes it (also re-runs the skipped
upload-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].version from pyproject.toml. They agree because CI checks out at
the tag — so keep the tag and pyproject in lockstep.