Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Tag-triggered release candidate preparation.
#
# What this does NOT do is publish a download. Signing needs the OK Studio
# hardware token, which is only exposed to an interactive session on the
# release workstation, and docs/release-plan.md is explicit that unsigned CI
# output is a development artifact and must never be promoted to a release
# asset. So this workflow does everything that does not require the key:
# it gates the tag against the declared version, proves the candidate builds
# and that the frozen executables carry the right version, publishes the
# unsigned bundle as a workflow artifact for inspection, and prepares a draft
# release pinned to the tagged commit for the signed installer to be attached
# to. See docs/build-windows.md for the signing and upload steps.
name: Release candidate

on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to rehearse the checks against, e.g. v0.1.0b1"
required: true

# Read-only by default; the one job that needs to write says so itself.
permissions:
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
candidate:
name: Gate the tag and build the candidate
runs-on: windows-latest
outputs:
version: ${{ steps.identity.outputs.version }}
prerelease: ${{ steps.identity.outputs.prerelease }}
steps:
- uses: actions/checkout@v4
with:
# Whatever triggered the run: the tagged commit on a tag push, and
# the selected branch on a dispatch. Deliberately *not* the dispatch
# input -- the point of the rehearsal is to run the checks against a
# tag that does not exist yet, and using the proposed tag as a
# checkout ref failed here before check_tag.py could say anything
# about it. The input is a version to gate, not a ref to fetch.
ref: ${{ github.ref }}
fetch-depth: 0

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install packaging dependencies
run: python -m pip install -e ".[gui]" -r requirements-build.txt

- name: Refuse a tag that disagrees with the declared version
id: identity
shell: bash
run: |
set -euo pipefail
# Writes `version` and `prerelease` to $GITHUB_OUTPUT itself, so the
# classification the draft is created with comes from the same gate
# that validated the tag rather than from a second reading of it.
python scripts/check_tag.py "${{ github.event.inputs.tag || github.ref }}"

- name: Install NSIS
run: choco install nsis --version=3.12.0 -y --no-progress

- name: Build the unsigned candidate
run: python build/windows/build.py --clean --no-sign

- name: Confirm the artifacts the release contract names
shell: bash
run: |
version="${{ steps.identity.outputs.version }}"
cd dist/windows
for name in "Offloader-Setup-${version}.exe" \
"Offloader-${version}-windows-x64.zip" \
"Offloader-${version}-inventory.json" \
"SHA256SUMS.txt"; do
test -f "$name" || { echo "missing artifact: $name"; exit 1; }
done
echo "--- SHA256SUMS.txt ---"
cat SHA256SUMS.txt

- uses: actions/upload-artifact@v4
with:
name: offloader-candidate-${{ steps.identity.outputs.version }}-unsigned
path: |
dist/windows/Offloader-*.exe
dist/windows/Offloader-*.zip
dist/windows/Offloader-*-inventory.json
dist/windows/SHA256SUMS.txt
if-no-files-found: error
retention-days: 30

draft:
name: Prepare the draft release
needs: candidate
# Only a pushed tag. The ref test alone was not enough: a dispatch can be
# started against an existing tag, and `github.ref` is then a tag ref too,
# so a rehearsal reached this job, took the write token and edited the
# release -- including passing --draft to one that had been published.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.ref }}

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Create or refresh the draft, without assets
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.candidate.outputs.version }}
PRERELEASE: ${{ needs.candidate.outputs.prerelease }}
shell: bash
run: |
set -euo pipefail
python scripts/release_notes.py --tag "$TAG" --commit "$GITHUB_SHA" \
--out release-notes.md
# From the validated version, not assumed. check_tag.py accepts a
# stable tag, and publishing one classified as a prerelease leaves
# the installer outside GitHub's /releases/latest, which is the feed
# the updater reads. Set explicitly on both paths so a rerun after a
# version change corrects the classification rather than inheriting
# whatever the first run chose.
#
# Refreshed rather than replaced: re-running a tag must not discard
# a signed asset already uploaded against it.
if gh release view "$TAG" >/dev/null 2>&1; then
gh release edit "$TAG" --draft --prerelease="$PRERELEASE" \
--title "Offloader $VERSION" --notes-file release-notes.md
else
gh release create "$TAG" --draft --prerelease="$PRERELEASE" \
--title "Offloader $VERSION" --notes-file release-notes.md \
--target "$GITHUB_SHA"
fi
echo "Draft prepared for $TAG with no assets; upload the signed installer."
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,33 @@ project uses [semantic versioning][semver].

### Added

- **A tag-triggered release candidate workflow.** Pushing `v*` gates the tag
against `src/offloader/_version.py` before spending a packaging run on it,
builds the unsigned bundle and installer on `windows-latest`, confirms every
artifact the release contract names exists, uploads them for inspection, and
prepares a draft pinned to the tagged commit. It attaches no
assets: signing needs the hardware token that only the release workstation
has, and the release plan requires every Windows download to be signed, so
the signed installer is uploaded separately. `contents: write` is held only
by the drafting job, and a test asserts no job in the workflow can attach
what it built to a release. The tag gate is `scripts/check_tag.py`, sharing
one version grammar with the updater and the installer's Windows fields, so
a tag that cannot be published is refused rather than producing an asset
nothing can compare.

Whether the draft is marked a prerelease comes from the version the gate
validated, and is set on both the create and the refresh path, so a rerun
corrects an existing draft rather than inheriting the first run's choice. A
stable release created as a prerelease would sit outside GitHub's
`/releases/latest`, which is the feed the updater reads.

`workflow_dispatch` rehearses the checks against a tag that does not exist
yet: the proposed tag is a version to gate, not a ref to fetch, so the run
checks out whatever commit it was started from. Only a *pushed* tag may touch
a release — a dispatch can be started against an existing tag, and the ref
test alone let a rehearsal take the write token and edit the release,
including passing `--draft` to one already published.

- **`offloader update` finds, verifies and applies a newer release.** GitHub
Releases is the feed, so there is no manifest server and no second place a
version is written down. Before anything runs: HTTPS with a host allowlist
Expand Down
63 changes: 63 additions & 0 deletions docs/build-windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,69 @@ project license and distribution metadata. ffmpeg and ffprobe remain external.
Missing media tools reduce metadata/thumbnails, not copy verification. A
release-ready third-party license inventory and SBOM remain separate work.

## Tagging a candidate

Pushing a `v*` tag runs
[`release.yml`](../.github/workflows/release.yml), which prepares a candidate
but deliberately does not publish one.

```powershell
python scripts/check_tag.py v0.1.0b1 # run the gate before pushing
git tag v0.1.0b1
git push origin v0.1.0b1
```

The first thing it does is refuse a tag that disagrees with
`src/offloader/_version.py`, before spending a packaging run on it. That
mismatch is worth catching early because it does not look like a failure
later: the release publishes, the installer installs, and the fault appears as
an update every installed copy declines, because the updater compares the
feed's tag against the version compiled into the installer.

It then builds unsigned on `windows-latest`, checks the frozen executables
carry the right version, confirms the artifacts the release contract names all
exist, and uploads them as a workflow artifact. Finally it prepares a **draft**
pinned to the tagged commit, with notes and no assets.

Whether that draft is marked as a prerelease comes from the version the gate
just validated, not from an assumption. `v0.1.0b1` is a prerelease and `v1.0.0`
is not, and a stable release created as a prerelease would sit outside GitHub's
`/releases/latest` — the feed the updater reads — so every installed copy would
go on declining the release meant for them. It is set explicitly on both the
create and the refresh path, so a rerun corrects an existing draft's
classification rather than inheriting whatever the first run chose.

No assets, on purpose. Signing needs the hardware token, which exists only on
the release workstation, and the [release plan](release-plan.md) requires every
Windows download to be signed. So the workflow's own output is for inspection,
and the signed installer is uploaded separately:

```powershell
git checkout v0.1.0b1
python build\windows\build.py --clean
python build\windows\build.py --verify-only
gh release upload v0.1.0b1 dist\windows\Offloader-Setup-0.1.0b1.exe dist\windows\SHA256SUMS.txt dist\windows\Offloader-0.1.0b1-inventory.json
```

`workflow_dispatch` runs the same checks without touching releases, for
rehearsing a tag before it exists. The proposed tag is a version to gate, not a
ref to fetch: the run checks out whatever commit it was started from, so
entering a `vX.Y.Z` that has no ref yet reaches `check_tag.py` instead of
failing in checkout. Re-running a tag refreshes the draft's notes rather than
recreating it, so a signed asset already uploaded is not discarded.

Only a *pushed* tag may touch a release. A dispatch can be started against an
existing tag, in which case `github.ref` is a tag ref too, so the drafting job
requires the event as well as the ref. Without that, a rehearsal took the write
token and edited the release — including passing `--draft` to one that had
already been published.

`tests/test_release_workflow.py` asserts the negative property this depends on:
that no job in the workflow attaches what it built to a release. It also
evaluates the drafting job's condition against all three cases — tag push,
branch dispatch, tag dispatch — with only the first permitted to mutate
anything.

## Check the artifact

Installer implementation validation on 2026-09-10 (Windows x64, Python 3.12.10,
Expand Down
10 changes: 9 additions & 1 deletion docs/release-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ tests or builds were run for this documentation task.
| --- | --- | --- |
| Product | Engine, CLI, Qt desktop app, reports, BRAW/BWF support, optional timeline import | Exercise the frozen application against representative workflows |
| Version | One source in `src/offloader/_version.py` used by package metadata and the Windows bundle | Confirm the frozen release identity across all published assets |
| CI | Windows/macOS/Linux tests on Python 3.13, Linux Python 3.10, ffmpeg job, property-test soak, wheel/sdist build and metadata checks | Install built artifacts in fresh environments; build and smoke-test Windows desktop artifacts |
| CI | Windows/macOS/Linux tests on Python 3.13, Linux Python 3.10, ffmpeg job, property-test soak, wheel/sdist build and metadata checks; a tag-triggered candidate workflow that gates the tag against the declared version, builds unsigned, and prepares a draft with no assets | Install built artifacts in fresh environments; attach signed assets from the release workstation |
| Distribution | Frozen bundle, NSIS installer path, source and bundle inventories, and checksums | Hardware-key signing, clean-machine installation, release workflow, and publication documentation |
| Dependencies | Minimum versions and optional extras | Recorded build environment and pinned release dependency sets |
| Media tools | ffmpeg/ffprobe discovered externally; copying works without them | Explicit installer dependency policy and useful missing-tool messaging |
Expand Down Expand Up @@ -140,6 +140,14 @@ Suggested implementation files: `build/windows/offloader.spec`,
`docs/build-windows.md`. Follow Alpha-OSK's separation of build, sign, and
publish, rather than assuming its scripts are drop-in compatible.

`.github/workflows/release.yml` now implements the preparation half of this:
it fails on a tag/version mismatch before building, builds unsigned, requires
every artifact the contract names to exist, and prepares a draft pinned to the
tagged commit with `contents: write` held only by the drafting job. It attaches
nothing, because hosted CI cannot sign; signature verification and asset upload
remain release-workstation steps. Alpha-OSK has no release automation to copy
here, so this is new work rather than parity.

The release workflow should prepare a draft with narrowly scoped permissions,
pin the source commit and build environment, and fail on version mismatch,
missing assets, failed checks, or invalid signatures. Inventory bundled native
Expand Down
93 changes: 93 additions & 0 deletions scripts/check_tag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Fail unless a release tag names the version the source declares.

python scripts/check_tag.py v0.1.0b1
python scripts/check_tag.py refs/tags/v0.1.0b1

A tag and a version literal that disagree produce a release whose assets,
installer metadata, Add/Remove Programs entry and update feed all claim
different things. The updater compares the feed's tag against the version
compiled into the installer and refuses a mismatch, so the failure would not
surface as a bad release: it would surface later as an update that every
installed copy declines, for reasons nobody can see from the outside.

Checking it in a script rather than inline in the workflow keeps it testable,
and lets the same gate run locally before a tag is pushed.
"""

from __future__ import annotations

import argparse
import os
import re
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))

from offloader._version import __version__ # noqa: E402
from offloader.update import parse_version # noqa: E402

#: Tags are pushed as `v0.1.0b1`; a workflow hands over the full ref.
_PREFIXES = ("refs/tags/", "v")

#: A stable release is `X.Y.Z` and nothing else. Everything the version
#: grammar allows after that -- a, b, rc -- is a prerelease.
_STABLE_RE = re.compile(r"^\d+\.\d+\.\d+$")


def version_from_tag(tag: str) -> str:
"""The version a tag names, with the ref path and `v` prefix removed."""
value = tag.strip()
for prefix in _PREFIXES:
if value.startswith(prefix):
value = value[len(prefix):]
return value


def is_prerelease(version: str) -> bool:
"""Whether a version names a prerelease rather than a shipping release.

The draft's classification is derived from this rather than assumed. A
stable release created as a prerelease stays outside GitHub's
`/releases/latest`, which is the feed the updater reads, so every installed
copy would keep declining the release that was meant for them.
"""
return _STABLE_RE.match(version.strip()) is None


def _emit_outputs(version: str) -> None:
"""Hand the workflow what it needs, from the gate that validated it."""
destination = os.environ.get("GITHUB_OUTPUT")
if not destination:
return
with open(destination, "a", encoding="utf-8") as handle:
handle.write(f"version={version}\n")
handle.write(f"prerelease={'true' if is_prerelease(version) else 'false'}\n")


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tag", help="the release tag, or its full ref")
args = parser.parse_args(argv)

tagged = version_from_tag(args.tag)
if parse_version(tagged) is None:
print(f"error: {args.tag!r} does not name a release version. Tags look "
f"like v0.1.0 or v0.1.0b1.", file=sys.stderr)
return 2
# Compared as text, not as parsed tuples: `0.1.0` and `0.1.0+1` would
# order the same while naming different things on disk.
if tagged != __version__:
print(f"error: tag {args.tag!r} names version {tagged!r}, but "
f"src/offloader/_version.py declares {__version__!r}. Bump the "
f"version literal and commit before tagging.", file=sys.stderr)
return 1
_emit_outputs(__version__)
kind = "prerelease" if is_prerelease(__version__) else "stable release"
print(f"tag {args.tag} matches the declared version {__version__} ({kind})")
return 0


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading