Release process¶
A release is cut by merging a release-please PR, finalizing the changelog and publishing the resulting draft release. This page is the maintainer runbook for that flow and for the signing and verification steps that follow.
Pushes to master drive a release-please workflow that maintains one release PR. Merging that PR creates a draft release; publishing the draft creates the tag and triggers the full build, signing and publication pipeline. The automation flow below shows each step.
Version scheme¶
Releases follow ordinary SemVer tags, vX.Y.Z:
Xchanges for incompatible public-surface changes.Ychanges for backward-compatible features.Zchanges for backward-compatible fixes.
The fork's first release is v1.0.0. It is preceded by release candidates v1.0.0-rc.N; v1.0.0-rc.1 and v1.0.0-rc.2 are published. Netflix's tags (v1.0.2 to v3.2.1) are not tags of this repository: the 26 that had been copied into it were deleted on 2026-10-05 (the record, ADR-1805). A vX.Y.Z tag you see in a clone that is not a fork release came from git fetch upstream with tags. The 3.2.x baseline that used to be in the manifest was a source-version alignment with Netflix's SONAME, not a fork release.
release-please runs in prerelease mode (versioning: prerelease, prerelease-type: rc, release-please-config.json), and .release-please-manifest.json holds the current candidate (1.0.0-rc.2). The final cut flips prerelease to false once the candidate sequence passes (see Cutting a release from fragments). ADR-1151 governs the first release and supersedes ADR-1127's "start at v3.2.1"; ADR-1201 adds the candidate channel.
Example progression:
v1.0.0 # first VMAFx release
v1.0.1 # patch release
v1.1.0 # backward-compatible feature release
v2.0.0 # incompatible public-surface release
First-release candidate responsibilities¶
ADR-1341 separates the first release into ordered evidence stages. The stage-to-tag map has been revised twice:
- ADR-1421 maps the stages to tags and supersedes the candidate mapping of ADR-1352, which had added the stabilisation candidate after rc.1.
-
ADR-1490 inserts RC7 and moves the later candidates up by one, so each phase number matches its tag.
-
ADR-2001 folds the cloud-native work, a GStreamer element, an OBS-ready API and real- time FFmpeg GPU scoring into RC4, RC5 and RC8, the full CPU SIMD ladder into RC7 and legacy GPU build variants into RC6, all without new numbers, and replaces the post-1.0 milestones with the themed releases 1.1 to 1.5 (below). It is the last change of the map until 2.0, apart from bugs and findings.
- ADR-2342 records the scope decisions of 2026-10-06 and 2026-10-07 without new numbers: more RC4 work packages, more RC5 tool-consolidation work and the post-1.0 placement of the issues filed since.
The user-facing version of this map is the roadmap.
Candidate map¶
| Candidate | Proves | Must not be used to claim |
|---|---|---|
v1.0.0-rc.1 (RC1) | Release-blocking correctness is closed or explicitly deferred, required checks pass on the exact head, and outside testers can run the bounded hardware-validation/report path | Final performance or production-trained model quality |
v1.0.0-rc.2 (RC2) | The dependency and fix train since rc.1 meets the RC1 bar: no confirmed release blocker or untriaged docs/state.md row, required checks pass on the exact head, and the tester report path still works | Final performance or production-trained model quality |
v1.0.0-rc.3 (RC3) | Twin exactness: every GPU and SIMD twin returns the CPU extractor's scores bit for bit or carries a measured tolerance recorded in an ADR, 8K cells included; no SYCL kernel uses scratch memory; no extractor or twin overflows at 8K or 16K with 16-bit samples (ADR-1880). Rows that only a device the project does not own can close (unreached CUDA and HIP architectures, Xe-LP, the Windows GPU builds) are carried past rc.3 with a stated reason (ADR-1707) | Performance, production-trained model quality, or exactness on a device the release notes list as not yet verified |
v1.0.0-rc.4 (RC4) | The whole vmaf_v1.0.16_3d0h path (cambi, speed_chroma, integer adm3, integer motion3, model prediction) runs in Rust, bit-identical to C, with the C ABI unchanged; a device-resident frame is scored through the import API with fences and no host copy of pixel data (ADR-1829); the new VMAFx API and its generated surfaces, the VMAFx-named FFmpeg filters and provenance on every score pass with the golden-data gate run through the compatibility library (ADR-1868); the versioned scoring API contract and server mode with observability are generated from the same definition; the native vmafx GStreamer element and the conformance run of upstream's gst-plugins-bad vmaf element on the compatibility libvmaf.so.3 pass; the API is ready for OBS Studio (texture import including OpenGL interop, asynchronous window scores, bounded queues); real-time FFmpeg GPU scoring with n_stats works (ADR-2001); the language bindings are generated from the definition; the FFmpeg patch series is redesigned from 0001 after an audit of everything VMAFx uses in FFmpeg, and builds without warnings on every measured compiler; host input of every semi-planar and packed layout, RGB with a stated matrix and every bit depth from 8 to 16 is bit-exact with the planar path; motion2 and motion3 are incremental so live windows complete before the flush; Vulkan frames import on CUDA, SYCL and HIP; docs/principles.md is per language with each missing gate proven by a planted defect; the observability package the Linux arm64 and older-glibc binaries and the package-manager channels, and the cloud-native platform (state out of the processes, scaling on queue depth, CRDs, proto, OpenAPI and the Helm schema generated from the definition) pass their end-to-end tests (ADR-2342) | Performance, or production-trained model quality |
v1.0.0-rc.5 (RC5) | One implementation per behaviour across GPU twins, host code and tools, with libgpudispatch extracted (it folds in the per-backend import code RC4 wrote); the new metrics (ΔE-ITP, PU21, NIQE, BRISQUE, Y-FUNQUE+, HDR-SSIM, HDR-MS-SSIM, XPSNR) and the Metal SpEED twins are written once on it, each twin exact or within a measured libm bound; device-targeted scoring (device profiles mapped onto nvd / rdh, one decode scored for many targets, ADR-1880); containers, Helm chart and a kind plus kuttl test setup, the operator, the controller / node split and the GPU pool arbiter in libgpudispatch (ADR-2001); the Go tools replace the Python MCP server and vmaf-tune; live alignment of two feeds with timecode, interlaced video, region masks, container input through FFmpeg libraries, bits per pixel and BD-rate, HandBrake support, the minimal run-result timeline and one reusable build-and-provenance workflow for every image pass their tests, and the Vulkan compute experiment has a written verdict (ADR-2342) | Performance, or production-trained model quality |
v1.0.0-rc.6 (RC6) | The checked-in GPU capability table matches the vendor toolchains (CI drift check), dispatch and kernel parameters read it, and every kernel, the RC5 twins included, passes the static audit for every target; the table lists the legacy build variants (CUDA 12.x for sm_50 to sm_72, the Intel legacy compute runtime for Gen9 to Gen11, every AMD target the pinned ROCm compiler emits), each bit-exact, and declares each backend's and device's format envelope (up to 16K with measured memory limits, 8 to 16 bits, chroma layouts, odd and portrait sizes), every row test-backed (ADR-1880) | Measured performance on any device |
v1.0.0-rc.7 (RC7) | The checked-in CPU capability table matches the compile flags and runtime gates (CI drift check), no SIMD kernel contains an instruction outside the feature set its gate guarantees, and every dispatch level is bit-exact against scalar under emulation (details below the table); every extractor has a bit-exact kernel at every useful ISA level of x86-64, AArch64, RISC-V RVV 1.0, POWER VSX and LoongArch LSX/LASX, with qemu-user CI and the golden gate where no hardware exists (ADR-2001); the CPU format envelope is declared and test-backed in the same table (ADR-1880); every extractor with an original implementation is proven against it in a reference conformance column, the default is reference-exact and Netflix's behaviour is a named compatibility mode that the golden gate runs in (ADR-2343) | Measured performance on any processor |
v1.0.0-rc.8 (RC8) | Benchmarks, profiles, and tuning results are comparable, reproducible, and still numerically correct on the tested hardware, throughput per resolution up to 16K included and distributed throughput across nodes (ADR-2001); the training tooling is ready (data hygiene, evaluation tooling, HDR conversion checks) and the mini retrain passes every stage | Completion of the real retraining programme |
v1.0.0-rc.9 (RC9) | The one-shot real retrain, started only when every precondition of #1246 holds, trained on reference-exact features (the shipped v1 models read compatibility-mode features until then), and its quality, provenance, registry, signing, and golden-data gates pass on the tuned tree | That no later repair candidate can be needed |
After 1.0.0¶
Every minor release runs its own candidate cycle in the order of the rules below: features first, then deduplication, tests and bug fixing, then the capability tables with benchmarks and tuning, then training where models change, then the release (ADR-2001).
| Release | Theme |
|---|---|
| 1.1 | Integrations and live quality: the OBS Studio plugin (#2239), #2148, #2147, #2144, #2146, #2159, no-reference mode in the plugins (#2413), the live P.1204 monitor (#2417), AI-generated video scoring (#2418), WebRTC, streaming outputs, libVLC and cookbook recipes |
| 1.2 | Encoder feedback, embedding and platforms: the rest of #2067, #2164, #2156, encoder-side predictors (#2416), the VLC plugin (#2358), mobile and WebAssembly targets |
| 1.3 | New metrics with exact twins: #2165, #2167, #2166, picks from #2168, our own no-reference models (#2415), foveated scoring (#2419) |
| 1.4 | Metric A/B comparison, the best current mix and more training data: #2240, #2241 |
| 1.5 | The next model generation: #2242 |
| 2.0 | Breaking changes only: the libvmaf.h compatibility library removed (ADR-1852 D7), the C++23 core, the rest of #1254 |
RC7 evidence in detail¶
- The instruction audit is per function, for x86 and aarch64.
- Emulation covers Intel SDE CPU models and qemu for aarch64 NEON and SVE2 at more than one vector length.
- Reports from real Xeon or Apple Silicon machines are extra evidence, not a requirement.
Rules for candidates¶
Candidate tags are immutable. RC1 to RC9 name the planned candidate for each responsibility; if a stage finds a correctness defect, land the fix and rerun the affected evidence before advancing. A later repair candidate may be cut without moving benchmark work into RC1 to RC7 or real training before RC8 evidence is accepted. Speed that RC3 gives up for exactness is recorded as a tuning row and recovered in RC8, never traded back for a tolerance. The final v1.0.0 follows accepted RC9 evidence.
Every report and acceptance record identifies:
- the exact commit and the published artifact or image digest;
- fixtures, host and device, drivers and runtimes, tool versions;
- commands, exit codes and raw logs.
A green check or benchmark from a different head is not evidence for the candidate being evaluated.
Ordinary Renovate and version-update PRs are not frozen between candidates. They merge under the same required checks, review, digest/pin policy and component-specific validation as any other change. Coordinated major SDK, toolchain and base-image updates keep their specialised validation. If one merges after evidence was collected, rerun the checks or measurements it can affect against the new exact head.
The VMAFx release stream advances independently of Netflix/vmaf. Upstream alignment remains recorded in sync commits and release notes, not encoded in the tag.
Product version vs. libvmaf ABI SONAME¶
These are two different numbers and only the first one moves at release time.
| Number | Owner | Value today | Moves when |
|---|---|---|---|
| Product version | release-please | 1.0.0-rc.2 (the manifest value); 1.0.0 at final | Every release. Covers the vX.Y.Z tag, core/meson.build's project(version:), compat/python-vmaf, the three fork-local Python distributions (ai/, dev-llm/, mcp-server/vmaf-mcp/), and the Helm chart's appVersion. It does not reach libvmaf.pc — see ADR-1235. |
| ABI SONAME / interface version | hand-maintained | vmaf_soname_version = '3.0.0' at core/meson.build:38, shipping libvmaf.so.3 and advertised as libvmaf.pc's Version: | Only on a C API change. The 1.0.0 cut does not reset it. |
libvmaf.so keeps its 3.x SONAME while the product goes to 1.0.0, and libvmaf.pc advertises that same 3.x interface version rather than the product version. The coupling is deliberate and load-bearing:
- unpatched upstream FFmpeg's
configurerequireslibvmaf >= 2.0.0; - the fork's own
ffmpeg-patches/requirelibvmaf >= 3.0.0for the SYCL and DNN entry points.
A .pc advertising 1.0.0 satisfies neither. That is how the first release candidate failed the three FFmpeg lanes and Docker Image Build:
See ADR-1235.
Warning
Do not "align" the two numbers. The comments at core/meson.build:38 and at the pkg_mod.generate() call in core/src/meson.build say so at the source.
Coordinated version markers¶
release-please-config.json's extra-files array is the single list of coordinated version markers, and scripts/release/verify-release-version.sh derives its check list from that same array — add a release surface there and the preflight covers it automatically. Each listed file must carry exactly one x-release-please-version marker.
Deliberately not coordinated, and deliberately unannotated: deploy/helm/vmafx/Chart.yaml's version: (chart packaging, nothing publishes it), the three Cargo.toml crate versions, the root pyproject.toml tooling aggregate, and the ARG VMAFX_VERSION=dev defaults in docker/Dockerfile.* (the publish workflows always pass the real value as a build argument). Each carries an inline comment saying so.
Automation flow¶
The flow in five steps (the diagram at the top of this page shows it graphically):
- release-please watches master. On each push it inspects Conventional Commit headers (
feat:,fix:,docs:,chore:,ci:, …) to determine whether a release is warranted. If so, it opens or updates one release PR that bumps the root manifest and every coordinated version marker. - Finalize the generated release PR. After all other release changes are merged, run
make docs-render(the rendered changelog, ADR index and rebase notes are written when pull requests land, so this is normally a no-op) and the fragment rollover with the PR's exact version and UTC date. Commit that result as the release PR's final change. Any later fragment invalidates the cut and must be rolled again. - Merging the release PR creates a draft GitHub release. It does not yet create the public release tag.
- An authenticated operator publishes the draft. Publication creates the
vX.Y.Ztag and emits therelease.publishedevent. This explicit gate is deliberate: publication is the irreversible step, and a human is the only actor allowed to take it. - The publication workflows check out the published release's immutable tag rather than the default branch. The Docker workflows derive their image tags from the same
github.event.release.tag_name; the other release jobs build libvmaf binaries and Python wheels. Every workflow signs and attests its outputs, runs its own smoke checks, and publishes only after those gates pass.
Text description
- Landing: Pull request (Conventional Commit title), PR gates (deliverables, state.md, audit), Merge train (make lint, make test), master (squashed commit).
- Release: release-please (release PR, version bump), Draft release (merged release PR), Operator publishes (creates the vX.Y.Z tag), Publication workflows (build, sign, attest, publish).
- Pull request → PR gates → Merge train → master (fast-forward) → release-please (push) → Draft release (merge) → Operator publishes → Publication workflows (release.published).
- Scenario 1, Land: A pull request becomes one commit on master. The PR body carries the deliverables; the gates check it and the diff. The merge train restacks the PR, builds and runs the tests. It lands as one squashed commit, fast-forward.
- Scenario 2, Release: master becomes a published release. release-please reads the commit titles and opens the release PR. Merging the release PR creates a draft release, no tag yet. An operator publishes the draft; that creates the tag. The workflows check out the tag, sign and publish.
- Release candidates follow the phases in AGENTS.md section 11: RC1 to RC8, then v1.0.0.
- Publishing the draft is the one irreversible step and is taken by a person.
What actually gates a release PR¶
A release PR's diff is .release-please-manifest.json plus the coordinated version markers. Under the Required Checks Aggregator's absent-means-pass rule (ADR-0313) that would let every path-routed build and test gate select nothing and still resolve green — correct for a doc-only PR, wrong for the one PR whose merge creates a release. Two things now prevent that:
.release-please-manifest.jsonandrelease-please-config.jsonare members of thec_coreselector in.github/ci-impact.json, so a release PR really does select the C build and the Netflix golden gate.- The aggregator keeps a
mustReportlist —Release Script Contract,Netflix CPU Golden,Ubuntu gcc+DNN— that fails instead of passing when a check is absent on arelease-please--head ref.
Release-bot identity¶
release-please.yml authenticates as a GitHub App (or, as a fallback, a personal access token), never as GITHUB_TOKEN.
Why¶
PRs and pushes made with secrets.GITHUB_TOKEN do not trigger further workflow runs. That is a GitHub loop-breaker, not a configuration mistake. It is why release PRs used to land as action_required with zero jobs: the sole required context could never report and the PR sat BLOCKED behind an admin bypass.
Every step in the job (both release-please invocations and both read-only gh api probes) uses the token resolved by the Resolve the release-bot token step. The job's own GITHUB_TOKEN keeps the workflow default contents: read and holds no write scope at all.
Identity modes¶
Two identities are accepted, in this order; the third row is the no-credentials state:
| Mode | Credential | When it is used |
|---|---|---|
app | RELEASE_BOT_APP_ID + RELEASE_BOT_PRIVATE_KEY | Preferred. Minted per run by actions/create-github-app-token, short-lived, scoped to this repository. |
pat | RELEASE_BOT_TOKEN | Fallback when the App secrets are absent. Needs repo + workflow. |
none | — | Pipeline stays idle: warning on push, error on workflow_dispatch (ADR-1171). |
Note
Prefer the App. A PAT is broader-scoped and longer-lived than an App installation token, and it carries whatever scopes its owner granted rather than only the two this workflow needs (Contents, Pull requests). The PAT path exists because registering a GitHub App has no API path: it is a browser flow and its private key is downloadable only once, at creation, so requiring the App made the whole release pipeline block on a manual step. Swap to the App when it exists and delete the PAT secret.
The same identity, resolved the same way (App, else PAT, else a failing step that names the missing secrets), creates the tag and release of the macOS tester bundle in macos-tester-bundle.yml: the job token cannot create the tag of a commit that differs from master's tree in .github/workflows/. Only that one step uses it (tester maintainer notes).
Whichever mode is active, the resolved token is masked with ::add-mask:: before it reaches any later step.
One-time App setup¶
Warning
One-time maintainer setup. Until the credentials exist the workflow never falls back to GITHUB_TOKEN, because that would recreate an unmergeable release PR.
Without credentials:
- On every push to
masterthe first step emits a warning annotation and skips every write step, so the run ends green and idle. - On a manual
workflow_dispatchthe missing credentials are an error and the run fails, because an operator asked for a release step (ADR-1171).
scripts/release/check-release-bot-secrets.sh checks the two secret names locally and is part of the /prep-release dry run, so a release cannot be attempted without the identity. To set it up:
- Create a GitHub App owned by the
VMAFxorg (name it e.g.vmafx-release-bot). Repository permissions: Contents: read & write and Pull requests: read & write. Nothing else. - Install it on
VMAFx/vmafxonly. - Generate a private key and add two repository secrets:
RELEASE_BOT_APP_ID— the App's numeric ID.RELEASE_BOT_PRIVATE_KEY— the full PEM, including the-----BEGIN…/-----END…lines.
The installation token is minted per run and revoked when the job ends; there is no long-lived credential and nothing to rotate on a schedule. A personal access token would also work mechanically but ties the release stream to one human's account and expires; see ADR-1151's alternatives.
Process gates on the release PR¶
Six process gates in rule-enforcement.yml are required contexts (see the branch-protection inventory below). Four of them encode authoring discipline and cannot be satisfied by a PR nobody writes by hand:
| Gate | Why a release PR cannot pass it unaided |
|---|---|
| Deliverables Checklist | release-please's body is the rendered changelog: no six-item checklist, no opt-out sentinel. |
| Doc-Substance Gate | the coordinated version markers include mcp-server/vmaf-mcp/pyproject.toml, which the gate path-maps to a mandatory docs/mcp/ edit. |
| docs/state.md Gate | the changelog body can carry a closes #N line inherited from a commit subject, which trips the bug-shaped heuristic. |
| FFmpeg-Patches Surface Sync | diff-driven, and a version-marker bump is not a patch-stack change. |
Each of those four jobs therefore runs scripts/ci/release-pr-exempt.sh first and skips its work step when the predicate says the PR is machine-generated. The predicate requires both a release-please-- head ref and a bot author, so pushing a branch named release-please--anything does not disarm a required gate. The jobs still report — they report green, not absent, which keeps them distinguishable from a path-filter skip.
The remaining two stay armed on release PRs on purpose:
Release Script Contractproves the cut ran and that the one-shotrelease-as/bootstrap-shafields are gone. It also runsscripts/ci/tests/test-release-pr-exempt.sh, so the predicate that disarms the other four is itself proven on every PR, release PR included.ADR Collision Guardis diff-driven and trivially green when no ADR is added.
To dry-run the predicate locally:
HEAD_REF=release-please--branches--master--components--vmafx \
PR_AUTHOR='vmafx-release-bot[bot]' PR_AUTHOR_TYPE=Bot \
bash scripts/ci/release-pr-exempt.sh
Publication environments¶
Before publication, repository setup must provide two protected environments:
release-publishfor GHCR writes, release-blob signing, build-provenance attestations, and GitHub Release attachment;pypi-publishfor thevmaf-mcpTrusted Publisher identity.
Each must accept selected tag refs matching v* and require the release reviewer.
Warning
GitHub auto-creates a referenced environment that does not exist, with an empty rule set. A write-bearing job naming a missing environment therefore runs straight through with no approval gate, and scripts/release/tests/test-publication-environment-binding.sh only greps the YAML, so it cannot see that server-side drift.
supply-chain.yml's validate-release queries both environments over the API and fails closed unless each carries a required_reviewers protection rule. That preflight is read-only and runs before any job holds write or OIDC scope.
The two build-provenance jobs (provenance for the native files, mcp-provenance for the vmaf-mcp distributions) hold OIDC and attestation write scope, so they run in release-publish as well, with contents: read. Each writes its attestation bundle as a workflow artifact; the protected attachment job is the sole job that uploads the two bundles to the GitHub Release (ADR-1356).
Native Linux release layout¶
Download the CLI and the whole libvmaf.so* and libvmafx.so* chains into one directory; the CLI finds both libraries next to itself. The native files attached by supply-chain.yml are currently Linux ELF artefacts.
Download¶
Meson builds a three-name dynamic-library chain for each of the two libraries: libvmaf.so, its ABI SONAME such as libvmaf.so.3, and its ABI real name such as libvmaf.so.3.0.0; and the same for libvmafx (libvmafx.so, libvmafx.so.1, libvmafx.so.1.0.0). Since the library split (ADR-2094) the CLI and the compat libvmaf.so.3 both need libvmafx.so.1. GitHub artifact and release downloads do not preserve symlinks, so the workflow publishes all six names as regular-file assets, identical within each chain. Each name is hashed, inventoried in both native SBOMs, signed, and listed as a subject of the native build-provenance attestation.
Restore the raw CLI asset's executable bit after the download. The CLI's only RUNPATH entry is $ORIGIN, the directory the CLI itself sits in, so it loads libvmaf.so.3 and libvmafx.so.1 from there without LD_LIBRARY_PATH, from any working directory, as long as the files stay together:
mkdir vmafx-linux && cd vmafx-linux
gh release download v1.0.0 --repo VMAFx/vmafx \
--pattern vmaf --pattern 'libvmaf.so*' --pattern 'libvmafx.so*'
chmod +x vmaf
./vmaf --version
readelf -d vmaf | grep RUNPATH # Library runpath: [$ORIGIN]
Note
The v1.0.0-rc.1 CLI predates this: it carries Meson's build-tree RUNPATH $ORIGIN/../src, which finds nothing next to the downloaded file. Run that release candidate as LD_LIBRARY_PATH="$PWD" ./vmaf --version.
Runtime requirements¶
The bundle needs x86-64 Linux with glibc 2.38 or newer and the libstdc++ of GCC 12 or newer. It is compiled on the fork's Debian 13 release track (the release-build stage of the dev container, glibc 2.41; ADR-1354), the same base the published container images are built on. Its newest symbol versions are GLIBC_2.38 (the C23 strtol / sscanf entry points) and GLIBCXX_3.4.30. Every release checks that it runs on:
| System | glibc | Checked by |
|---|---|---|
Ubuntu 24.04 (GitHub-hosted ubuntu-24.04) | 2.39 | verify-native-artifacts runs the full clean-environment verifier on the runner |
Distroless cc-debian13 (RELEASE_RUNTIME_CC, the base of the CLI image) | 2.41 | verify-native-artifacts starts the downloaded CLI in that image with no LD_LIBRARY_PATH |
Debian 13 (the release-build stage) | 2.41 | the release build runs the verifier inside the stage it compiled in |
Newer distributions, such as Ubuntu 26.04 (glibc 2.43), load it as well. It does not load on Ubuntu 22.04 (glibc 2.35) or Debian 12 (glibc 2.36); both fail with version 'GLIBC_2.38' not found. On those systems use the production containers or build from source. Check a host with ldd --version.
The bundle is CPU-only and built without the ONNX Runtime tiny-AI backend, so it needs no GPU runtime or libonnxruntime.
How the layout is built¶
scripts/release/build-native-release-artifacts.sh sets the CLI's RUNPATH $ORIGIN on the staged copy with patchelf; the build tree keeps Meson's $ORIGIN/../src. The clean-environment release gate, scripts/release/verify-native-release-artifacts.sh, exercises the layout before signing and again after an artifact upload/download round trip, with no LD_LIBRARY_PATH. It requires the CLI's RUNPATH to be exactly $ORIGIN (no other entry and no DT_RPATH), its DT_NEEDED entry to resolve to the staged SONAME file, and vmaf --version to report the release version.
Windows vmaf.exe remains a CI build artifact, not a GitHub Release asset, and this workflow currently publishes no macOS native CLI or dylib. Use the production containers or build from source for those platforms until platform-specific release bundles are introduced.
Canonical build environment (ADR-1346)¶
ADR-1102 requires native release artifacts to be built inside dev/Containerfile. ADR-1346 (superseding ADR-1178's self-hosted runner) meets that on a GitHub-hosted runner, and ADR-1354 puts the compile on the Debian 13 release track that build-config.env assigns to published artifacts. In short:
| Concern | What happens |
|---|---|
| Where | build-artifacts in .github/workflows/supply-chain.yml, on ubuntu-latest. |
| How long | An uncached stage build took under a minute and the release compile under a minute on four workstation CPUs. The job has a 60-minute limit. No workstation or self-hosted runner has to be online. |
| Compile | scripts/release/build-native-release-artifacts.sh inside the image. |
| Rehearsal | The Dev Container PR gate builds the same stage with the same script on every container-affecting PR. |
| Verification | verify-native-artifacts on ubuntu-24.04. |
| Recovery | A timed-out or failed build is re-run with the recovery dispatch below, which rebuilds the stage from the same tag. |
Where¶
build-artifacts builds the release-build stage of the release tag's own dev/Containerfile with scripts/ci/build-dev-container-stage.sh release-build. That stage is the digest-pinned Debian 13 base RELEASE_BUILDER_BASE plus Debian archive packages (GCC 14, Meson, Ninja, NASM, xxd, patchelf).
- It downloads nothing from third parties and needs no GitHub token.
- The build uses no external layer cache and no registry.
- Archive packages resolve when the stage is built, so a later rebuild of the same tag may use newer packages from a Debian point release.
- The exception is patchelf, which edits the published CLI:
dev/Containerfilepins it to Debian 13's package version (PATCHELF_VERSION).
Compile¶
The script runs with docker run --pull never --network none as the runner's user. It:
- refuses to build unless the checkout is
GITHUB_SHA; - configures Meson with
--buildtype=release -Denable_avx512=true -Denable_cuda=false -Denable_sycl=false -Denable_dnn=disabled -Denable_tests=false; - stages the bundle, setting the staged CLI's RUNPATH to
$ORIGIN; - stamps
container-build-provenance.txtwithscripts/ci/check-container-build.sh --stamp; - runs
scripts/release/verify-native-release-artifacts.sh.
The unit tests are left out because Debian 13's GCC 14.2 crashed at random while link-time optimising them; the release does not ship them, and other CI jobs build and run them.
Rehearsal¶
The Dev Container PR gate runs the same invocation against a local tag named after .release-please-manifest.json's version, so a change that would break the release compile fails there.
Verification¶
verify-native-artifacts runs on ubuntu-24.04, the oldest GitHub-hosted image that can load the bundle. It names that label rather than ubuntu-latest so the check cannot drift to a newer glibc. It:
- checks the stamp with
--verify; - exercises the downloaded CLI in a clean environment;
- starts the CLI in the release runtime image (
RELEASE_RUNTIME_CC, distrolesscc-debian13) with noLD_LIBRARY_PATH, so the RUNPATH$ORIGINis proven there too.
The stamp is signed with Cosign and attached as a release asset.
Release recovery dispatches¶
Use the manual supply-chain and Docker dispatches only to recover an existing, published GitHub release (a final release or a candidate). To re-run a workflow unchanged, run it at the immutable tag ref and pass that same tag as its input:
tag=vX.Y.Z
gh workflow run supply-chain.yml --ref "$tag" -f tag="$tag"
gh workflow run docker-publish-production.yml --ref "$tag" -f tag="$tag"
gh workflow run docker-publish-operator-node.yml --ref "$tag" -f tag="$tag"
Where each workflow may run:
supply-chain.ymlonly runs at the tag: its release binaries and their signatures must come from the tag's own workflow.- The two image workflows can also be dispatched on
masterwhen the tag's build recipe itself was broken. That run builds the tag's source withmaster'sdocker/recipe and signs asmaster(ADR-1347; see Recovering a release's container images).
Each preflight verifies the coordinated versions and the published GitHub release before any write or OIDC job starts. The protected deployment environments still apply on recovery runs; approval authorizes the write-bearing jobs only, after the read-only preflight has proved the tag/ref/release identity.
The moving latest container tag is resolved from the repository's newest published release rather than from the trigger event, so a recovery dispatch repoints latest when it is recovering the newest release and leaves it alone otherwise. (Before ADR-1151 the guard was github.event_name == 'release', so a recovery run left latest pointing at the broken original digest.)
ADR index regeneration policy¶
docs/adr/README.md is the rendered index of every ADR in the fork. Its "Index" table is generated from per-ADR fragments under docs/adr/_index_fragments/<slug>.md. The renderer is scripts/docs/concat-adr-index.sh (see ADR-0221 for why the pattern exists and ADR-2197 for who writes the render).
Rendered at landing, not in the pull request¶
A pull request does not carry CHANGELOG.md, docs/adr/README.md, docs/adr/by-tag/, docs/adr/titles.md, docs/research/titles.md or the fragment block of docs/rebase-notes.md (ADR-2197). scripts/ci/deliverables-check.sh refuses a pull request that edits one of them. The outputs are written by one command, make docs-render:
- per landing batch: the merge train runs it at the batch tip and commits the result as
chore(docs): render generated changelog and ADR index, in the same push as the batch, so every master tip is rendered; - at the release cut: see Cutting a release from fragments;
- by hand: to see what the render will write, run it in a scratch worktree; it is safe to run at any time and writes nothing else.
make docs-render-check fails when a render is stale. The master push runs it (the Docs job), so a push that skipped the render turns master red instead of passing silently. A pull request does not run it: make docs-fragments-check checks the fragments themselves (changelog sections, ADR index rows, rebase-note headings) and the outputs that stay in the pull request (exact-twin table, AGENTS indexes, charts, vendored assets).
The render needs full history: the order of the index rows and of the rebase notes is the order the fragments landed on the branch, read by scripts/docs/fragment-order.py. A shallow clone fails loudly.
Adding a new ADR¶
When adding a new ADR (the common case) — write the fragment docs/adr/_index_fragments/<slug>.md as part of the same PR, and nothing else: do not touch README.md or _order.txt. _order.txt is the frozen list of the rows that existed when the index moved to fragments; every other fragment follows it in landing order.
Fixing drift¶
When fixing drift between fragments and README.md (this sweep's case) — run make docs-render-check to capture the full diff, then audit each row against the four drift classes:
- Silent loss — fragment exists, README is missing the row. Regenerating with
--writekeeps the fragment's row. - Orphan content — README has a row, fragment does not exist. Backfill the fragment from the README row (the row content already reflects the ADR's accepted state). Do not delete the row without evidence the ADR is genuinely stale (
Status: WithdrawnorSupersededin the ADR file body, plus the underlying decision being moot). - Reformatted — same content, different shape (column order, status spelling, slug case). Regenerate; the fragment is canonical.
- Duplicate — the same row appears more than once in
README.md, usually from a stale append-only edit. Regenerate; the fragment is emitted exactly once.
After every fragment-side fix, run --write once and verify the README diff matches the audit's expected shape (rows preserved, duplicates collapsed, missing rows restored). Reviewers can re-run --check against the rebuilt branch and expect a clean exit.
Renumbered slugs. When the dedup sweep referenced in the script's header comment renumbers an ADR (e.g. 0270-saliency-… → 0286-saliency-…), the fragment must be renamed to match the new slug — not duplicated. A rename of a fragment listed in the frozen _order.txt changes that entry too; a rename of any other fragment moves its landing position, so rename in the commit that adds it. The fragment body's [ADR-NNNN](NNNN-slug.md) link must match the renumbered slug; mismatches silently render rows that point at non-existent ADR files. The fragment-vs-ADR-file slug audit is one line:
for f in docs/adr/_index_fragments/[0-9]*.md; do
base=$(basename "$f" .md)
[[ -f "docs/adr/$base.md" ]] || echo "STALE FRAGMENT: $f"
done
Signing¶
All release artefacts are signed via Sigstore keyless using the repository's GitHub OIDC identity. No long-lived signing keys live in the repo or in CI secrets.
What is signed¶
- Release blobs (
libvmaf.so*,libvmafx.so*,vmaf,models.tar.gz,container-build-provenance.txt,THIRD_PARTY_NOTICES.txt,licenses.tar.gz, optionalu2netp_mirror.{onnx,pth}): cosign sign-blob bundles attached to the GitHub Release, plus one GitHub build-provenance attestation (actions/attest-build-provenance) that lists every file as a subject. The attestation is an in-toto statement with the SLSA v1 build-provenance predicate, signed through Sigstore; it is stored with the repository's attestations and attached asvmafx-build-provenance.sigstore.json. SPDX + CycloneDX SBOM attached; the SPDX SBOM is also attested on the same subjects withactions/attest(gh attestation verify <file> --repo VMAFx/vmafx --predicate-type https://spdx.dev/Document/v2.3). See ADR-1356. - Licences of the release blobs (ADR-1513):
build-native-release-artifacts.shwritesTHIRD_PARTY_NOTICES.txt(every component, licence and copyright line oflibvmafandvmaf, computed from the SPDX headers of the files the build compiled) andlicenses.tar.gz(the same file with every licence text), andmodels.tar.gzcarries its ownlicenses/directory for the models. Both are written and checked bytools/rc1-tester/image/licensing.py(artifact kindsrelease-native,release-models); the build fails on a file without a recorded licence. Both tarballs aregzip -9n(ADR-1591); the container layers are zstd at BuildKit's strongest level and need Docker Engine 23.0 or later to pull (ADR-1594, table in Artifact publishing policy). vmaf-mcpPython package (wheel + sdist): cosign sign-blob bundles, a GitHub build-provenance attestation attached asvmaf-mcp-provenance.sigstore.json, an SPDX SBOM attestation on the wheel and sdist, and PEP 740 attestations stored alongside the PyPI artefact (Trusted Publishing, no token). See ADR-0166.- Production container images (
ghcr.io/vmafx/vmafx:<tag>and the-cuda13/-rocm10/-oneapi2026(also tagged-oneapi2025) /-servervariants): cosign keyless signature plus a GitHub-native build-provenance attestation (actions/attest-build-provenance). See ADR-0902. - Go service images (
ghcr.io/vmafx/vmafx-server:<tag>,ghcr.io/vmafx/vmafx-controller:<tag>(since ADR-1589),ghcr.io/vmafx/vmafx-operator:<tag>, andghcr.io/vmafx/vmafx-node:<tag>): the same cosign signature, CycloneDX SBOM, and GitHub-native build provenance, emitted bydocker-publish-operator-node.yml. Each is built per architecture on a native runner and published as one multi-arch index, which carries the signature, SBOM and provenance (ADR-1349).
Consumer verification recipes¶
The OIDC identity is the workflow path inside the repo. The release blobs and MCP wheel come from supply-chain.yml; the container images come from docker-publish-production.yml.
# The release tag to verify. v1.0.0-rc.1 and v1.0.0-rc.2 use the
# slsa-verifier recipe further down.
tag=v1.0.0
# Release blob. Every vmaf/libvmaf.so*/libvmafx.so* asset has a matching FILE.bundle.
cosign verify-blob --bundle vmaf.bundle vmaf \
--certificate-identity \
"https://github.com/VMAFx/vmafx/.github/workflows/supply-chain.yml@refs/tags/${tag}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# Release blob, build provenance (ADR-1356). gh fetches the attestation from
# GitHub. Works for every native asset; the vmaf-mcp wheel and sdist from the
# GitHub Release verify the same way.
gh attestation verify vmaf --repo VMAFx/vmafx \
--signer-workflow VMAFx/vmafx/.github/workflows/supply-chain.yml \
--source-ref "refs/tags/${tag}"
# The same check against the attached bundle, without the attestations API.
# Fetch the Sigstore trusted root once while online; after that no network
# access is needed.
gh attestation trusted-root > trusted_root.jsonl
gh attestation verify vmaf --repo VMAFx/vmafx \
--bundle vmafx-build-provenance.sigstore.json \
--custom-trusted-root trusted_root.jsonl \
--signer-workflow VMAFx/vmafx/.github/workflows/supply-chain.yml \
--source-ref "refs/tags/${tag}"
# vmaf-mcp wheel on PyPI. The PyPI integrity API supplies the PEP 740
# provenance; pypi-attestations binds it to the expected source repository.
pypi-attestations verify pypi \
--repository https://github.com/VMAFx/vmafx \
pypi:vmaf_mcp-1.0.0-py3-none-any.whl
# Container image, cosign route. Replace DIGEST with the actual sha256 digest.
cosign verify ghcr.io/vmafx/vmafx@sha256:DIGEST \
--certificate-identity \
"https://github.com/VMAFx/vmafx/.github/workflows/docker-publish-production.yml@refs/tags/${tag}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# Container image, GitHub-native attestation route (added by ADR-0902).
gh attestation verify oci://ghcr.io/vmafx/vmafx@sha256:DIGEST --repo VMAFx/vmafx
# Go server/operator/node images use their own workflow identity.
cosign verify ghcr.io/vmafx/vmafx-node@sha256:DIGEST \
--certificate-identity \
"https://github.com/VMAFx/vmafx/.github/workflows/docker-publish-operator-node.yml@refs/tags/${tag}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
supply-chain.yml runs the provenance recipe above against the attached bundle for every subject before the bundle can reach the release, so a release whose provenance does not verify is never published.
v1.0.0-rc.1 and v1.0.0-rc.2 predate ADR-1356. They carry slsa-github-generator provenance as vmafx-build-provenance.intoto.jsonl and vmaf-mcp-provenance.intoto.jsonl instead of the .sigstore.json bundles and have no GitHub attestation. Verify those with slsa-verifier:
slsa-verifier verify-artifact vmaf \
--provenance-path vmafx-build-provenance.intoto.jsonl \
--source-uri github.com/VMAFx/vmafx --source-tag v1.0.0-rc.2
An image rebuilt by a recovery run was signed by the workflow on master, not at the tag, so its identity ends in @refs/heads/master. Such an image carries the label io.vmafx.build-recipe=<master commit>; every v1.0.0-rc.1 image was built this way. Check the label, then verify with the master identity:
docker buildx imagetools inspect ghcr.io/vmafx/vmafx:v1.0.0-rc.1 \
--format '{{json .Image}}' | jq -r '.. | .Labels? // empty | ."io.vmafx.build-recipe"'
cosign verify ghcr.io/vmafx/vmafx@sha256:DIGEST \
--certificate-identity \
"https://github.com/VMAFx/vmafx/.github/workflows/docker-publish-production.yml@refs/heads/master" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# vmafx-server, vmafx-operator and vmafx-node come from the other workflow.
cosign verify ghcr.io/vmafx/vmafx-node@sha256:DIGEST \
--certificate-identity \
"https://github.com/VMAFx/vmafx/.github/workflows/docker-publish-operator-node.yml@refs/heads/master" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
For a recovered image the tag identities above fail with no matching CertificateIdentity found, and so does gh attestation verify pinned to the tag with --source-ref refs/tags/<tag> or --source-digest <tag commit>. The GitHub build-provenance attestation records the run that built the image: refs/heads/master and the recipe commit. The built source is the tag's commit, which a recovery run also writes into org.opencontainers.image.revision. The v1.0.0-rc.1 images were recovered before that label was corrected, so theirs names the recipe commit a919f3596 instead of the tag's ce00cf245.
Post-push smoke jobs¶
The post-push smoke jobs in both Docker workflows run the matching cosign verification recipe before pulling an image, with the identity of the run that signed it (@${GITHUB_REF}: the tag, or master for a recovery run). After that:
- the production workflow executes the CPU CLI and the Python 3.14 server entrypoints;
- the Go-service workflow starts the Go scoring server and probes
/healthzplus/readyz; - it also checks the operator version and executes
vmaf --versionplusffmpeg -versionfrom the node image.
A signature or runtime-linkage gap fails the release rather than silently shipping a broken image.
CHANGELOG.md fragment workflow (ADR-0221)¶
The "Unreleased" block of CHANGELOG.md is rendered from per-PR fragment files under changelog.d/<section>/*.md by scripts/release/concat-changelog-fragments.sh. Sections follow Keep-a-Changelog order: added → changed → deprecated → removed → fixed → security. The pre-fragment archive lives verbatim in changelog.d/_pre_fragment_legacy.md and is emitted before the section fragments so existing release-train history is preserved.
When to add a fragment vs edit CHANGELOG.md directly¶
- Always add a fragment, never edit
CHANGELOG.mddirectly. Drop a single Markdown bullet underchangelog.d/<section>/<topic>.md. The fragment is the source of truth; the renderedCHANGELOG.mdis a build artefact that a pull request does not carry (ADR-2197). - Filename convention: lowercase kebab-case, optionally prefixed with the task ID (
T7-39-foo.md) or ADR number (adr-0312-deferral-retired.md) for implicit lexical ordering within the section. - One fragment per PR. Multi-surface PRs may ship multiple fragments, one per user-discoverable surface, each in the appropriate section.
When to regenerate (--write)¶
make docs-render writes the changelog with the other rendered outputs: the merge train runs it once per landing batch and the release cut runs it. You need it by hand only to preview a render, or to reconcile a skew:
make docs-render-check(the master push) fails on a stale render.- Pre-existing skew (see the 2026-05-08 sweep) is reconciled by rendering on master, not by a feature branch: a pull request that carries a render is refused.
Never edit the rendered "Unreleased" block by hand to add new entries — those inline edits will be silently overwritten by the next regen.
Drift classes and resolution policy¶
Three drift classes can develop between fragments and the rendered block:
| Class | Symptom | Resolution |
|---|---|---|
| Silent loss | Fragment exists, no matching row in CHANGELOG.md. | Regenerate. The fragment is canonical. |
| Orphan content | Row in CHANGELOG.md, no matching fragment. | Backfill a fragment if the content is still relevant; delete the row otherwise. Inspect each case manually — never bulk-delete. |
| Duplicate | Same entry appears twice (often once from legacy archive, once from a fragment, or once inline + once from a fragment). | Regenerate. The script renders each fragment exactly once. |
--write is conservative: it only rewrites the ## [Unreleased] block. Released sections below are untouched.
Cutting a release from fragments¶
Release-please has skip-changelog: true; it never edits CHANGELOG.md. The cut is two commits on the generated release PR, made after it contains the final manifest and version-marker updates.
- Label the release PR
autorelease: cutso release-please leaves the branch alone (see Freezing the release PR while you cut it). -
Render the final notes:
-
Roll the fragments over into the versioned section. Replace the example version and UTC date for other releases:
-
Push both commits, merge the release PR and publish the draft release.
The rollover requires:
- a clean tree;
- exact agreement between the root manifest and every coordinated marker;
- zero renderer drift;
- a unique target heading;
- a non-empty active source set.
It then removes the consumed fragments and legacy source, leaving their exact content in the versioned changelog section and a SHA-256 receipt under changelog.d/releases/. The removals are recoverable from Git history. A second identical invocation is a no-op.
A long body goes to docs/changelog-archive/X.Y.Z.md (see --archive-over). The first release's archive holds the whole fragment history and is larger than the 1 MB check-added-large-files limit, so top-level Markdown files in that directory are exempt from it (ADR-1345). Nothing else in the directory is.
Before the cut: every tester leg green on the commit¶
A pull request builds only the tester legs whose inputs it changed, and a master push skips a leg whose inputs did not change, so a leg can be red for days unseen (the x64 SYCL Windows zip was, from 2026-10-05 to the rc.3 publish run). Before you label the release pull request autorelease: cut, dispatch every leg at the commit the release is cut on (the release pull request's base, the head of master) with the full SHA, so the run title names its source:
SHA=$(git rev-parse origin/master)
for wf in windows-tester-bundle.yml macos-tester-bundle.yml docker-publish-tester.yml; do
gh workflow run "$wf" -R VMAFx/vmafx --ref master -f ref="$SHA"
done
Then check them (the same script the cut pull request runs, see below):
It lists every leg of scripts/release/candidate-legs.json that is not success on that commit: skipped, absent and red all count as not green, and a dispatch titled master or a tag proves nothing about a commit. The Release Script Contract job runs the same check on a release pull request that carries the autorelease: cut label, against the pull request's base commit, so the cut cannot merge on an unseen or red leg (ADR-2198). When master moves after the dispatch, dispatch again at the new head.
Cutting a release candidate¶
A release candidate is cut the same way, with its full version, for example --version 1.0.0-rc.3. The script accepts exactly the shapes the tag-time verifier accepts, X.Y.Z and X.Y.Z-rc.N. Each candidate gets its own ## [1.0.0-rc.3] - YYYY-MM-DD section and changelog.d/releases/1.0.0-rc.3.json receipt.
The one-shot release-as and bootstrap-sha fields are no longer in release-please-config.json. The rollover still deletes them if present, because the tag-time verifier refuses them at every tag, candidates included.
Numbering later candidates¶
Later candidates are numbered automatically. The root package uses release-please's prerelease versioning (ADR-1348): a fix, feature or breaking change on 1.0.0-rc.1 gives 1.0.0-rc.2, and so on. The release PR stays open as a proposal; merge it only when the next candidate is due, and check its title before cutting it. For the final release, set "prerelease": false: the same strategy then proposes 1.0.0.
Note
With the earlier versioning: default, a fix on 1.0.0-rc.1 gave 1.0.1-rc.1. The Release Script Contract job now rejects that setting while the manifest is a release candidate.
How a candidate moves through the rest of the pipeline:
- Draft pause. While a
vX.Y.ZorvX.Y.Z-rc.Ndraft waits for publication,release-please.ymlskips both release-please phases, so later pushes tomasterneither move nor duplicate it. More than one waiting draft stops the job for operator cleanup. - Version strings. A release build checked out at its tag reports the
git describeform fromvmaf --version, for examplev1.0.0-rc.1-0-gd0f0e7eorv1.0.0-0-g8820048.scripts/release/verify-native-release-artifacts.shaccepts exactly that form (distance 0) or the bare version, never a later commit. - Python distributions. Python tools name distributions by the PEP 440 spelling,
1.0.0rc1, not1.0.0-rc.1.supply-chain.ymlderives that spelling once withscripts/release/pep440-version.shand matches thevmaf-mcpwheel and sdist by it. - Pushing the cut. The local pre-push PR-body hook exempts the bot release PR, as CI's Deliverables Checklist does, through
scripts/ci/release-pr-exempt.sh. The exemption needsghto be authenticated and the PR's head ref to be the branch being pushed.
ADR-1128 governs this cutover.
Recovering a release's container images¶
The image workflows (docker-publish-production.yml, docker-publish-operator-node.yml) normally run at the release tag with the tag's own workflow file. If an image job fails because of the build recipe, fix it on master, then dispatch the workflow on master with the published tag (ADR-1347).
The release-publish environment admits only v* tags, so a run on master is rejected at the environment gate ("Branch "master" is not allowed to deploy to release-publish") before any step runs. Allow master for the recovery only, then take the permission away again:
# 1. Let master deploy to release-publish; the required reviewer still applies.
policy=$(gh api -X POST \
repos/VMAFx/vmafx/environments/release-publish/deployment-branch-policies \
-f name=master -f type=branch --jq .id)
# 2. Dispatch the recovery runs and approve their release-publish deployments.
gh workflow run docker-publish-production.yml --ref master -f tag=v1.0.0-rc.1
gh workflow run docker-publish-operator-node.yml --ref master -f tag=v1.0.0-rc.1
# 3. After both runs have finished (re-runs need the policy too), remove it.
gh api -X DELETE \
"repos/VMAFx/vmafx/environments/release-publish/deployment-branch-policies/$policy"
gh api repos/VMAFx/vmafx/environments/release-publish/deployment-branch-policies \
--jq '.branch_policies[] | "\(.type) \(.name)"' # expect only: tag v*
The run verifies the published tag, builds the tag's source with master's build recipe, and labels every image io.vmafx.build-recipe=<master commit>. The recipe is docker/, Dockerfile.go-server and ffmpeg-patches/, the patch series for the FFmpeg that the node image bundles (ADR-1350). Everything else, including all VMAFx and libvmaf code, is the tag's.
Making the container images public¶
The five packages (vmafx, vmafx-server, vmafx-controller, vmafx-operator, vmafx-node) must be public so that anyone can pull the release images. vmafx-controller does not exist until the first release that publishes it (ADR-1589): after that publish, check its visibility and, if it was created private, switch it as described below. The organization setting Organization settings → Packages → Package creation → Public decides what a new package gets; it has been checked since v1.0.0-rc.1:
- Setting checked. A package first pushed from this public repository is created public, as
vmafx-operatorwas for v1.0.0-rc.1, and no further step is needed. - Setting unchecked. The package is created private, and the per-package option is greyed out ("disabled by organization administrators").
vmafxandvmafx-serverwere created this way before v1.0.0-rc.1. Once the setting is checked, an organization owner switches such a package once in the web UI: Package settings → Danger Zone → Change visibility → Public, then types the package name to confirm. The REST API cannot do this, because the packages endpoints offer only read and delete.
Later pushes keep the package's visibility.
Check that an anonymous client can pull:
token=$(curl -s "https://ghcr.io/token?scope=repository:vmafx/vmafx-node:pull" | jq -r .token)
curl -s -H "Authorization: Bearer $token" \
https://ghcr.io/v2/vmafx/vmafx-node/tags/list | jq '.tags'
Freezing the release PR while you cut it¶
Label the release PR autorelease: cut before you push the cut commits.
release-please force-recreates release-please--branches--master--… on every push to master: the branch always ends up with exactly one bot-authored commit. The two cut commits are hand-added to that same branch, so any merge to master after you push them silently destroys the cut.
While the label is present, release-please.yml skips its PR-update invocation and the branch is left alone; it still creates the draft release once the PR merges. The procedure is:
- Hold merges to
master(or accept that you may have to redo the cut). - Label the release PR
autorelease: cut. - Push the two rollover commits.
- Merge the release PR, then publish the draft release.
- If you abandon the cut, remove the label and release-please regenerates the branch from scratch on the next push.
scripts/release/verify-release-version.sh is the backstop: at tag time it requires exactly one ## [X.Y.Z] - YYYY-MM-DD heading, a matching changelog.d/releases/X.Y.Z.json receipt, zero active fragments, no _pre_fragment_legacy.md, and no surviving release-as / bootstrap-sha. A tag whose cut never ran cannot publish.
Drift-sweep cadence¶
CI runs --check on every PR (the docs-fragments lane) so new drift fails loud. A periodic drift-sweep PR (typically once per merge train) reconciles the pre-existing skew that accumulates when in-flight PRs add fragments faster than --write is run.
CHANGELOG drift sweep — historical context¶
The 2026-05-08 sweep cleared 13 silent-loss fragments, 1 reformatted entry (verbose inline → canonical fragment), and 2 duplicate rows (double ### Changed header + duplicate FastDVDnet entry). No genuine orphans were found — every row in CHANGELOG.md either had a matching fragment or lived in the legacy archive.
Dry-running a release¶
Before merging a release PR, invoke the /prep-release skill locally. It validates:
- All commits since the last release parse as Conventional Commits.
- The Netflix golden-data gate (CPU scalar + fixed-point) passes. GPU / SIMD backends are validated separately via per-backend snapshot tests.
CHANGELOG.mdrenders correctly and references no removed files.- Signing credentials (OIDC) resolve in the current CI environment.
Run the release-please preview from an origin-faithful clone, not directly from the development checkout:
- Local tags include tags fetched from the Netflix
upstreamremote. Those tags do not necessarily exist inVMAFx/vmafxand can make a local preview select the wrong previous release. - The preview clone must expose only the fork's advertised tags and the candidate
mastertree. - Supply credentials through a protected file descriptor or token-file path so the CLI never echoes a literal token in its argument list.
See section 11 of AGENTS.md for the one-line summary and the /prep-release skill definition for the full checklist.
master branch protection¶
master is protected at the GitHub API layer: the policy in the agent hard rules and CONTRIBUTING.md is enforced at the host, not just honoured by convention. The declared rule set and its drift check are in repository security.
- Required status check (1):
Required Checks Aggregator. Branch protection names exactly this one context; every other gate is enforced through it. - Linear history required. Merges are squash-or-ff-only.
- Force-push and deletion disabled.
- Admin bypass kept on. The owner can land emergency fixes that skip required checks; use sparingly (see the emergency-release section below).
Management: gh api --method PUT repos/VMAFx/vmafx/branches/master/protection with a JSON payload. The current rule set is documented in ADR-0037.
What the aggregator requires¶
The aggregator's own required array (.github/workflows/required-aggregator.yml) is the real inventory, about 80 contexts at the time of writing. Do not copy its count into branch-protection settings. A context missing from the array can be red while the merge button stays green (ADR-1297).
| Group | Contexts |
|---|---|
| Builds | Ubuntu gcc+DNN, Ubuntu clang+DNN, Windows UCRT64 (MSYS2 UCRT64), Windows MSVC+CUDA, Windows MSVC+SYCL, Ubuntu HIP, Ubuntu gcc, Ubuntu clang, Ubuntu ARM clang, Ubuntu gcc static, macOS clang, macOS clang+DNN, macOS Metal, Ubuntu CUDA, Ubuntu CUDA static, Ubuntu SYCL, Ubuntu SYCL+CUDA, Linux Intel LLVM, macOS Clang+Metal, Windows MSVC+CUDA (full), Windows ARM64 MSVC |
| Static analysis | CodeQL, CodeQL (C/C++), CodeQL (Python), CodeQL (Actions), Pre-Commit, Python Lint, Semgrep, Semgrep OSS, Tidy Changed, Tidy Ratchet, Tidy SYCL, Cppcheck, Markdown Lint, No Conflict Markers, Go API Compatibility |
| Supply chain and docs | Dependency Review, Gitleaks, gitleaks, Scorecard PR Gate (pull requests), Scorecard Master Gate (master pushes), Licence Provenance (guide), Docs, Docs Site Build, Doxygen Public API, ShellCheck + shfmt |
| Tests | Netflix CPU Golden, Coverage Gate, Coverage GPU, SYCL Parity (Arc A380), Sanitizers (address), Sanitizers (thread), Sanitizers (undefined), Sanitizers ASan+UBSan, Assertion Density, Twin Drift, Tiny AI, Tiny-Model Registry Validate, go vet + go test, MCP Smoke, RC1 Tester Report, vmafx-sys CI, cargo-deny |
| FFmpeg | FFmpeg Patch Stack, FFmpeg Ubuntu gcc, FFmpeg macOS clang, FFmpeg SYCL |
| Packaging and images | Docker Image Build, Dev Container Build, helm lint + template |
| Governance and HISS | Standards & Invariant Verification Gate (ADR-1249), HISS Replay Evidence (Linux), HISS Replay Evidence (macOS), HISS Replay Evidence (Windows), Silent-Revert Guard |
| Process gates | Deliverables Checklist, Doc-Substance Gate, docs/state.md Gate, FFmpeg-Patches Surface Sync, ADR Collision Guard, Release Script Contract |
Notes on the table¶
- The build lanes are
libvmaf-build-matrix.ymllegs andbuild.ymlrows (ADR-1259). Thebuild.ymlWindows row is namedWindows MSVC+CUDA (full)so that it cannot stand in for the requiredWindows MSVC+CUDAlane. Coverage GPUandSYCL Parity (Arc A380)are enforced only while their distinctGPU_COVERAGE_ENABLED/SYCL_ARC_RUNNER_ENABLEDvariables aretrue; hosted probes prevent dispatch to missing label sets (ADR-1319).Coverage Gate(about 40 minutes) is built with-fprofile-update=atomicto survive parallel-meson SIMD-counter races (ADR-0110).- The process gates report on every non-draft PR. Four of the six auto-exempt the machine-generated release PR; see Process gates on the release PR. The other two stay armed there.
- A
strictMustReportlist in the aggregator holds the governance and always-reporting gates whose absence fails the check instead of reading as a path-filter skip.
Note
When adding, renaming or removing a gate, update the aggregator's required array in the same PR and run scripts/ci/check-aggregator-names.sh. Branch protection's contexts list does not change, because it only ever names the aggregator.
Emergency release (out-of-band)¶
If a CVE requires an out-of-band release that bypasses the release-please PR:
- Branch off
masterintohotfix/CVE-YYYY-NNNN. - Land the fix with a
fix:commit and a signed-off-by line. - Manually tag the next ordinary patch version,
vX.Y.(Z+1); release-please will reconcile on the next regular push. - Backport the CVE fix to any active stacked release branches.
Upstream parallel¶
The upstream Netflix release process (manual version bump, manual CHANGELOG editing, draft-a-release on GitHub) is documented at Netflix/vmaf — release.md. It does not apply to this fork.
Do not fetch Netflix's tags into a clone of this repository. After adding the remote, set
git remote add upstream https://github.com/Netflix/vmaf.git
git config remote.upstream.tagOpt --no-tags
so git fetch upstream brings branches only. A tag fetched by mistake is removed locally with git tag -d <name>; the lefthook pre-push guard (scripts/git-hooks/check-push-tags.py) refuses to push a Netflix tag or a tag name outside the fork's patterns, and release-please, tester workflows and archive/* tags keep working.
Go consumers and the inherited tags¶
Until 2026-10-05 the repository carried Netflix's tags, and go get github.com/VMAFx/vmafx@latest resolved to Netflix's v3.0.0+incompatible. Measured with Go 1.27.1 (the experiments are summarised in ADR-1805):
- The Go command reads retractions only from the
go.modof the highest version, and Netflix'sv1.5.3(nogo.mod) outranked every forkv1.0.x, so aretractcould not take effect. - After the deletion
GOPROXY=direct go list -m -versions github.com/VMAFx/vmafxprintsv1.0.0-rc.1 v1.0.0-rc.2andgo list -m github.com/VMAFx/vmafx@latestprintsv1.0.0-rc.2; no Netflix version is listed. proxy.golang.orgkeeps what it has cached: it still answered@latest = v3.0.0+incompatibleand listed the Netflix versions on 2026-10-05, and it offers no purge. Its@v/<version>.zipstays served for every cached version by explicit request.
Until the proxy's @latest shows a fork version, pin an explicit version:
or bypass the proxy cache: GOPROXY=direct go list -m -versions github.com/VMAFx/vmafx. After v1.0.0 exists, go list -m -retracted -versions github.com/VMAFx/vmafx shows retracted releases too; keep the retract directive for the release candidates in every later go.mod, because only the latest version's go.mod is read.
Release notes and the changelog archive (ADR-1233)¶
Two different surfaces, produced two different ways:
| Surface | Produced by | Grouped by |
|---|---|---|
| GitHub Release body | GitHub, from merged pull requests | Labels, via .github/release.yml |
CHANGELOG.md | scripts/release/concat-changelog-fragments.sh, from changelog.d/ | changelog.d/ subdirectory |
The GitHub body has no slot for hand-written text. A statement the release must carry that no pull request title expresses, such as the rc.3 table of devices verified and not yet verified (ADR-1707), lives in a changelog.d/ fragment (so CHANGELOG.md has it) and the release publisher adds it to the release page with gh release edit --notes-file, keeping the generated body.
Because GitHub groups the release body by label, .github/workflows/pr-type-label.yml derives a type:* label from each PR's Conventional-Commit prefix. If a release body shows a large "Other changes" section, that labeler is not running — the prefix is mandatory, so the label should always be derivable.
Archiving an oversized release section¶
rollover-changelog-fragments.sh --archive-over N (default 400) keeps CHANGELOG.md readable. When the rendered body exceeds N lines it is written to docs/changelog-archive/X.Y.Z.md and the version section keeps a per-section index linking to it:
## [1.0.0] - 2026-09-07
This release collects 2345 changelog entries.
They are recorded in full, unedited, in
[`docs/changelog-archive/1.0.0.md`](docs/changelog-archive/1.0.0.md) — too long to read inline here.
| Section | Entries |
| --- | --- |
| Fixed | 1227 |
| Changed | 559 |
Nothing is discarded: the archive is tracked, and the rollover receipt still records the sha256 of the complete rendered body. Pass --archive-over 0 to force the old inline behaviour.