# Releasing Ferrofin Ferrofin ships three artifacts from this repo: the **server image** (`ghcr.io/mangoleaf/ferrofin`), the **Helm chart** (`oci://ghcr.io/mangoleaf/ferrofin/charts/ferrofin`) and **binary tarballs** attached to the GitHub Release. Image and chart are versioned **independently**. This is the operational checklist. **GitHub is the source of truth.** Public releases are cut by the `Release` workflow in `.github/workflows/release.yml`. The maintainer's homelab GitLab (`.gitlab-ci.yml`) runs an internal copy of the same pipeline; its tags are never pushed to GitHub. ## The versioning model **The git tag is the single source of truth for the version.** CI never edits version files: the release version is injected at build time — into the image via `SERVICE_VERSION` and into the chart via `helm package --version/--app-version` — so the committed `Cargo.toml`/`Chart.yaml` versions stay as dev placeholders. Never bump them for a release, and never push a hand-made tag: the workflow derives the next version from Conventional Commits since the last tag and pushes the tag itself (override its choice with the `force_version` input when needed). ## Tag scheme | Tag | Triggers | Notes | |---|---|---| | `vX.Y.Z` | image `:vX.Y.Z` + `:latest`, GitHub Release (a draft until its notes are written) with binaries, chart `X.Y.Z` | Final release | | `vX.Y.Z-rc.N` | image `:vX.Y.Z-rc.N` and binaries only | Pre-release; never moves `:latest`, no Release, no chart | | `chart-vA.B.C` | chart `A.B.C` only | Chart-only fix (templates/values), no app change | SemVer: **breaking → major, feature → minor, fix → patch.** Release tags are annotated. Note that `scripts/version.sh` never bumps to the next *major* on its own while the major is `0`; use `force_version` for that. ## Cutting an app release `vX.Y.Z` Pick the branch to release from: - **Feature releases** (a new minor or major) are cut from the current `release/vX.Y.Z` branch. Only one exists at a time. - **Patch releases** of the version already out (`vX.Y.Z+1`, bug fixes only) can be cut directly from `main`, which carries the released line. Either way, the steps are the same; "the release branch" below means the branch you release from. 1. **Ensure the branch is green** — the `CI` workflow (lint, tests, per-crate coverage gate, the `dependencies` supply-chain check) must have succeeded on the commit you are releasing; the workflow refuses otherwise, and re-runs `cargo deny check advisories` before it tags, so an advisory published since that CI run blocks the release too. `main` and `release/vX.Y.Z` get CI on every push, and the workflow reads that push run. The release PR (release branch → `main`) shows the same run's checks and runs no CI of its own. 2. **Freeze and measure the release branch.** PRs are not benchmarked individually, so this is the only performance gate. **Overview.** For a feature release, before the freeze, refresh the release PR's description (release branch → `main`; a patch release from `main` has no such PR) with a high-level overview of everything since the previous release: what changed by area, the scale, and the upgrade notes. The `github-release-overview` skill collects the commits and PRs and drafts it. **Upgrade notes.** Before the freeze, every release, patch releases included, gets its `## X.Y.Z` section in [`docs/UPGRADING.md`](docs/UPGRADING.md), written from the **Upgrade note** lines of the pull requests it contains: what an operator must do or plan for, grouped by who has to act or when, a line or two each, or "Nothing to do." when no pull request has one. It lands through a pull request into the branch you release from. The `github-release-overview` skill drafts it, for a patch release too, which has no overview. **Freeze.** From here until the tag exists, nothing merges into the release branch. If its head moves anyway, start again from step 1: the workflow tags the head at the time it runs. **Benchmark gate.** Compare the release commit against the previous Ferrofin release with the interleaved A/B gate in [`gates/bench/gate.py`](gates/bench/gate.py). The gate measures Ferrofin only; Jellyfin is not part of it. `vPREV`, here and below, is **the latest release of the minor line before the release** (owner decision 2026-10-29): v1.4.1 and v1.4.2 compare against the newest `v1.3.*` (v1.3.2), v1.5.0 against the newest `v1.4.*`, and a `vX.0.0` against the newest release of major X-1, never against an earlier release of their own minor line. Every command below that takes it refuses any other image before it starts and names the right one ([`gates/bench/baseline.py`](gates/bench/baseline.py), from the checkout's tags: fetch them first). It knows the release from the `release/vX.Y.Z` branch; for a patch cut from `main`, or a checkout of the release commit that is detached, add `--release vX.Y.Z` to each `gate.py` command (`gates/run --release`, `make run-gates RELEASE=`): without either, a command takes it for work in progress and checks nothing. ```bash export FERROFIN_GATES_DIR=/srv/gates # the gates' output root: the corpus and every session (gates/lib.sh) docker build -t ferrofin:vX.Y.Z-rc . # the frozen release commit docker pull ghcr.io/mangoleaf/ferrofin:vPREV # the previous release python3 gates/bench/gate.py ab ghcr.io/mangoleaf/ferrofin:vPREV ferrofin:vX.Y.Z-rc python3 gates/bench/gate.py scan ghcr.io/mangoleaf/ferrofin:vPREV ferrofin:vX.Y.Z-rc ``` - **What it runs.** Six runs in the order previous, candidate, previous, candidate, previous, candidate (three adjacent pairs). Each is `gates/bench/run.sh --servers ferrofin --only shape,unloaded,loaded`. The driver retags `ferrofin:bench` before each run, checks that the run measured the intended image, and restores the tag at the end. A failed run stops the session. It is never retried: start a new session. - **Oracle pin.** A release is measured against the Jellyfin release its branch pins: `UPSTREAM_TAG`, the Jellyfin oracle, pinned by digest in `gates/bench/testdata/jellyfin-image.txt`. The pin is the latest Jellyfin release when the release branch is cut, and the branch keeps it to its release; the next release branch moves to the newest release (owner decision 2026-10-25). Before the first run the session reads the pin and refuses to start only when it cannot read it. It also looks up the latest Jellyfin release (network access to GitHub): a newer one, or a lookup that failed, is noted, never refused. The note is recorded with the oracle in `gate.json`, `verdict.json` and the report ("measured against Jellyfin v12.2, the release this branch pins; v12.3 is out"). - **Wall time.** Not yet measured on the shared host. The fixed waits alone add up to 8 minutes per run: four 30 s settles (after startup and before each window and the steady phase), two 30 s warm-ups with 120 s windows, and the 60 s steady window. Six runs therefore take at least 48 minutes, before boot and the shape pass. Each run also needs the server and client cores 90 % idle (`run.sh` refuses otherwise), so plan for a quiet hour. TODO: replace this estimate with the measured time of the first gate session (`time python3 gates/bench/gate.py ab …`). - **Output.** `$FERROFIN_GATES_DIR/runs/bench-gate-ab--