# Cut Release

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

- Skill: `screenly/cut-release` (Agent Skill)
- Install (CLI): `npx skillmds@latest add screenly/cut-release`
- Raw SKILL.md: https://api.skillmd.com/api/skills/screenly/cut-release/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: screenly (https://skillmd.com/u/screenly)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/screenly/cut-release

---


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

1. `package.json` → `"version": "2026.06.0"` (zero-padded)
2. `pyproject.toml` → `version = "2026.06.0"` (zero-padded)
3. `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:

```bash
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.

```bash
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:

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

```bash
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.

