From 65732f424a865f251f414804e520f07d10c826e4 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:50:33 -0400 Subject: [PATCH 1/3] Prepare a release candidate from a tag, without publishing one Pushing a v* tag now gates the tag against the declared version, builds the unsigned bundle and installer, confirms every artifact the release contract names exists, and prepares a draft prerelease pinned to the tagged commit. It attaches nothing, and that is the design rather than a gap. Signing needs the hardware token, which is only exposed to an interactive session on the release workstation, and the release plan requires every Windows download to be signed including a beta. So the workflow does everything that does not need the key and stops. A test asserts the negative property the whole arrangement rests on: no job attaches what it built to a release. That is one line away from being wrong at any time, and wrong in a way no test of the build itself would notice. The tag gate is a script rather than inline YAML so it is testable and can be run before pushing. It shares one version grammar with the updater and with the installer's Windows version fields, so a tag that cannot be published is refused here instead of producing an asset nothing can compare. A tag that disagrees with the version literal is the mistake worth catching early: the release would publish, the installer would install, and the fault would surface later as an update every installed copy declines, because the updater compares the feed's tag against the version compiled into the installer. The draft notes are generated by a script too. Prose with backticks and blank lines inside a YAML block scalar inside a shell heredoc has three levels of quoting to get wrong, and the failure mode is a note that truncates at the first surprise. It refuses a tag that contradicts the version, since the heading names the tag while the instructions name filenames built from the version. Write permission is held only by the drafting job; the job that runs third-party packaging tools has no token that can publish. Re-running a tag refreshes the notes rather than recreating the release, so a signed asset already uploaded against it is not discarded. A workflow_dispatch run rehearses the checks without touching releases. Alpha-OSK has no release automation to copy: its releases are built locally and published by hand. This is new work rather than parity. --- .github/workflows/release.yml | 132 +++++++++++++++++++++++++++++++ CHANGELOG.md | 14 ++++ docs/build-windows.md | 43 ++++++++++ docs/release-plan.md | 10 ++- scripts/check_tag.py | 64 +++++++++++++++ scripts/release_notes.py | 86 ++++++++++++++++++++ tests/test_check_tag.py | 67 ++++++++++++++++ tests/test_release_workflow.py | 139 +++++++++++++++++++++++++++++++++ 8 files changed, 554 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/check_tag.py create mode 100644 scripts/release_notes.py create mode 100644 tests/test_check_tag.py create mode 100644 tests/test_release_workflow.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7a03eac --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +# 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 }} + steps: + - uses: actions/checkout@v4 + with: + # The version gate reads the working tree, so it has to be the + # tagged commit rather than a branch tip that has moved since. + ref: ${{ github.event.inputs.tag || 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 + python scripts/check_tag.py "${{ github.event.inputs.tag || github.ref }}" + python -c "import sys; sys.path.insert(0, 'src'); from offloader._version import __version__; print('version=' + __version__)" >> "$GITHUB_OUTPUT" + + - 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 on a real tag: a dispatch run is a rehearsal of the checks and + # should not touch releases. + if: 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 }} + shell: bash + run: | + set -euo pipefail + python scripts/release_notes.py --tag "$TAG" --commit "$GITHUB_SHA" \ + --out release-notes.md + # 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 --title "Offloader $VERSION" \ + --notes-file release-notes.md + else + gh release create "$TAG" --draft --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." diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8e5d5..afb384a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ 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 prerelease 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. + - **`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 diff --git a/docs/build-windows.md b/docs/build-windows.md index b944711..6a1ff1e 100644 --- a/docs/build-windows.md +++ b/docs/build-windows.md @@ -131,6 +131,49 @@ 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** +prerelease pinned to the tagged commit, with notes and no assets. + +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. Re-running a tag refreshes the draft's notes +rather than recreating it, so a signed asset already uploaded is not discarded. + +`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. + ## Check the artifact Installer implementation validation on 2026-09-10 (Windows x64, Python 3.12.10, diff --git a/docs/release-plan.md b/docs/release-plan.md index 346c5a2..d48859f 100644 --- a/docs/release-plan.md +++ b/docs/release-plan.md @@ -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 | @@ -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 diff --git a/scripts/check_tag.py b/scripts/check_tag.py new file mode 100644 index 0000000..e99d71a --- /dev/null +++ b/scripts/check_tag.py @@ -0,0 +1,64 @@ +"""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 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") + + +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 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 + print(f"tag {args.tag} matches the declared version {__version__}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 0000000..5ea764e --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,86 @@ +"""Write the draft release notes for a tagged candidate. + + python scripts/release_notes.py --tag v0.1.0b1 --commit "$GITHUB_SHA" + +Kept out of the workflow because prose with backticks and blank lines inside a +YAML block scalar inside a shell heredoc has three levels of quoting to get +wrong, and the failure mode is a release note that silently truncates at the +first surprise. + +The notes deliberately say the draft is not publishable. The workflow cannot +sign, the signing key only exists on the release workstation, and a draft that +looked finished is how an unsigned installer ends up as a download. +""" + +from __future__ import annotations + +import argparse +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 + +TEMPLATE = """\ +Offloader {version} + +Built from {commit}. + +**Not publishable yet.** The Windows download has to be the signed installer +produced on the release workstation. The artifact this workflow built is +unsigned and is attached to the workflow run for inspection only; see +`docs/release-plan.md`, which requires signing for any release including a +beta. + +To finish this release, from a non-elevated shell with the signing token +available: + +```powershell +git checkout {tag} +python build/windows/build.py --clean +python build/windows/build.py --verify-only +gh release upload {tag} dist/windows/Offloader-Setup-{version}.exe dist/windows/SHA256SUMS.txt dist/windows/Offloader-{version}-inventory.json +``` + +Then work through the acceptance matrix in `docs/release-plan.md` and publish +this draft only once every gate has recorded evidence. +""" + + +def notes(tag: str, commit: str, version: str = __version__) -> str: + """The draft body. + + Refuses a tag that does not name `version`. The workflow gates that before + it gets here, but the notes name artefact filenames built from the version + while the heading names the tag: if the two ever diverged, the result would + be a plausible-looking set of instructions pointing at files that do not + exist. Cheaper to make the invariant local than to rely on call order. + """ + tagged = tag.removeprefix("refs/tags/").removeprefix("v") + if tagged != version: + raise SystemExit( + f"error: tag {tag!r} names {tagged!r} but the source declares " + f"{version!r}; the release notes would contradict themselves") + return TEMPLATE.format(tag=tag, commit=commit, version=version) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--tag", required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--out", type=Path, default=None, + help="write here instead of standard output") + args = parser.parse_args(argv) + + body = notes(args.tag, args.commit) + if args.out is None: + sys.stdout.write(body) + else: + args.out.write_text(body, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_tag.py b/tests/test_check_tag.py new file mode 100644 index 0000000..c2b72e0 --- /dev/null +++ b/tests/test_check_tag.py @@ -0,0 +1,67 @@ +"""The release tag gate. + +A tag and the version literal disagreeing is the kind of mistake that does not +look like one: the release publishes, the installer installs, and the fault +appears later as an update every installed copy refuses, because the updater +compares the feed's tag against the version compiled into the installer. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +from offloader._version import __version__ + +REPO = Path(__file__).resolve().parent.parent + + +def _gate(): + path = REPO / "scripts" / "check_tag.py" + spec = importlib.util.spec_from_file_location("_check_tag", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("tag,expected", [ + ("v0.1.0", "0.1.0"), + ("0.1.0", "0.1.0"), + ("refs/tags/v0.1.0", "0.1.0"), + ("refs/tags/v0.1.0b1", "0.1.0b1"), + (" v0.2.0 ", "0.2.0"), +]) +def test_the_version_is_read_out_of_the_tag(tag: str, expected: str): + assert _gate().version_from_tag(tag) == expected + + +def test_the_declared_version_passes(): + assert _gate().main([f"v{__version__}"]) == 0 + + +def test_a_tag_naming_another_version_fails(capsys): + """The whole point. Exit 1 so a workflow stops before building.""" + assert _gate().main(["v99.0.0"]) == 1 + assert "_version.py" in capsys.readouterr().err + + +@pytest.mark.parametrize("tag", ["latest", "v1.2", "release-1", "v1.2.3-evil", + "v1.2.3.4", ""]) +def test_a_tag_that_is_not_a_release_version_fails(tag: str): + """Rejected by the same grammar the updater and the installer's Windows + version fields use, so a tag that cannot be published is refused here + rather than producing an asset nothing can compare.""" + assert _gate().main([tag]) == 2 + + +def test_the_gate_uses_one_grammar_with_the_updater(): + """If these ever diverge, a tag could pass the gate and then be invisible + to the updater, or vice versa.""" + from offloader import update + + module = _gate() + for value in ("0.1.0", "0.1.0b1", "latest", "1.2", "1.2.3-evil"): + publishable = update.parse_version(value) is not None + assert (module.main([value]) != 2) is publishable, value diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 0000000..d9a3a95 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,139 @@ +"""The tag-triggered release workflow and its notes. + +The property worth protecting is a negative one: `docs/release-plan.md` +requires every Windows download to be signed, signing needs a hardware token +that only exists on the release workstation, and hosted CI therefore builds +unsigned. So the workflow must never attach what it built to a release. That +is one line away from being wrong at any time, and wrong in a way no test of +the build itself would notice. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +yaml = pytest.importorskip("yaml", reason="PyYAML is a dev dependency") + +REPO = Path(__file__).resolve().parent.parent +WORKFLOW = REPO / ".github" / "workflows" / "release.yml" + + +def _notes_module(): + path = REPO / "scripts" / "release_notes.py" + spec = importlib.util.spec_from_file_location("_release_notes", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def workflow() -> dict: + with open(WORKFLOW, encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def _steps(workflow: dict) -> list[tuple[str, dict]]: + return [(job, step) + for job, spec in workflow["jobs"].items() + for step in spec["steps"]] + + +# -------------------------------------------------------------- the workflow + + +def test_it_triggers_on_version_tags(workflow): + # PyYAML reads the bare `on` key as the boolean True. + triggers = workflow.get("on", workflow.get(True)) + assert triggers["push"]["tags"] == ["v*"] + + +def test_nothing_it_builds_is_attached_to_a_release(workflow): + """The one that matters. CI cannot sign, and an unsigned installer offered + as a download is exactly what the release plan forbids.""" + for job, step in _steps(workflow): + script = step.get("run", "") + assert "release upload" not in script, \ + f"{job}/{step.get('name')} uploads a release asset" + + +def test_the_draft_is_created_as_a_draft(workflow): + """A release created non-draft is public the moment it exists, which for + an unsigned candidate is the failure this whole design avoids.""" + creating = [step for _job, step in _steps(workflow) + if "gh release create" in step.get("run", "")] + assert creating, "no step creates the draft" + for step in creating: + assert "--draft" in step["run"] + + +def test_write_permission_is_limited_to_the_drafting_job(workflow): + """The build job runs third-party packaging tools; it has no business + holding a token that can publish.""" + assert workflow["permissions"] == {"contents": "read"} + jobs = workflow["jobs"] + assert jobs["candidate"].get("permissions") is None + assert jobs["draft"]["permissions"] == {"contents": "write"} + + +def test_the_candidate_is_built_unsigned(workflow): + """Not a preference: the token is not present, and `build.py` refuses a + signed build from a dirty or unauthenticated environment anyway.""" + builds = [step for _job, step in _steps(workflow) + if "build/windows/build.py" in step.get("run", "")] + assert builds + for step in builds: + assert "--no-sign" in step["run"] + + +def test_the_tag_is_gated_before_anything_is_built(workflow): + """A tag that disagrees with the version literal should cost a few seconds, + not a full packaging run and a draft that has to be deleted.""" + steps = workflow["jobs"]["candidate"]["steps"] + scripts = [step.get("run", "") for step in steps] + gate = next(i for i, s in enumerate(scripts) if "check_tag.py" in s) + build = next(i for i, s in enumerate(scripts) if "build/windows/build.py" in s) + assert gate < build + + +def test_the_draft_job_waits_for_the_candidate(workflow): + assert workflow["jobs"]["draft"]["needs"] == "candidate" + + +def test_a_rehearsal_run_does_not_touch_releases(workflow): + """`workflow_dispatch` exists to exercise the checks. Guarded by a ref + condition so a manual run cannot create a draft for a tag that does not + exist.""" + condition = workflow["jobs"]["draft"]["if"] + assert "refs/tags/v" in condition + + +# ------------------------------------------------------------------ the notes + + +def test_the_notes_say_the_draft_is_not_publishable(): + body = _notes_module().notes("v0.4.0", "abc1234", version="0.4.0") + assert "Not publishable" in body + assert "signed installer" in body + + +def test_the_notes_name_the_artifacts_for_that_version(): + body = _notes_module().notes("v0.4.0", "abc1234", version="0.4.0") + assert "Offloader-Setup-0.4.0.exe" in body + assert "SHA256SUMS.txt" in body + assert "abc1234" in body + + +def test_the_notes_refuse_a_tag_that_contradicts_the_version(): + """Otherwise the heading names one release and the download instructions + name another's filenames, which reads as a broken release rather than a + mistagged one.""" + with pytest.raises(SystemExit, match="contradict"): + _notes_module().notes("v0.9.9", "abc1234", version="0.4.0") + + +@pytest.mark.parametrize("tag", ["v0.4.0", "0.4.0", "refs/tags/v0.4.0"]) +def test_the_notes_accept_a_tag_in_any_of_its_forms(tag: str): + assert _notes_module().notes(tag, "abc1234", version="0.4.0") From 6542b1c98de75cd5ac571a2555e289a240df2523 Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:09:05 -0400 Subject: [PATCH 2/3] Rehearse a tag that does not exist, and let only a push touch a release Three ways the workflow did not do what it says it does. The dispatch input was used as the checkout ref as well as the version to gate. Entering a proposed vX.Y.Z therefore failed in checkout before check_tag.py could say anything about it, which is the whole documented purpose of the rehearsal. The run now checks out whatever commit it was started from - the tagged commit on a push, the selected branch on a dispatch - and the input reaches only the gate. A tag is a version to validate, not a ref to fetch. The drafting job was guarded on the ref alone. A dispatch can be started against an existing tag, and github.ref is a tag ref then too, so a rehearsal reached the job with contents: write, created or edited the release, and passed --draft to one that had already been published. It now requires the event to be a push as well. --prerelease was passed unconditionally, while check_tag.py accepts stable tags. Publishing v1.0.0 as a prerelease leaves the installer outside GitHub's /releases/latest, which is the feed the updater reads, so every installed copy would go on declining the release meant for them. The classification comes from the version the gate validated, and is set explicitly on both the create and the refresh path, so a rerun corrects an existing draft rather than inheriting whatever the first run chose. check_tag.py writes version and prerelease to GITHUB_OUTPUT itself, so the facts the draft is built from come from the step that validated them rather than a second reading that could disagree. It writes nothing when the gate fails. Tests: the drafting condition evaluated against all three events rather than matched as text, since the defect was a condition that read correctly and was true in a case nobody had enumerated; the checkout ref not being the input and the gate still receiving it; the classification of six versions; and the outputs written on a pass and absent on a refusal. --- .github/workflows/release.yml | 37 +++++++++++----- docs/build-windows.md | 28 +++++++++++-- scripts/check_tag.py | 31 +++++++++++++- tests/test_check_tag.py | 38 +++++++++++++++++ tests/test_release_workflow.py | 77 +++++++++++++++++++++++++++++++--- 5 files changed, 191 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a03eac..74e2fb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,12 +36,17 @@ jobs: runs-on: windows-latest outputs: version: ${{ steps.identity.outputs.version }} + prerelease: ${{ steps.identity.outputs.prerelease }} steps: - uses: actions/checkout@v4 with: - # The version gate reads the working tree, so it has to be the - # tagged commit rather than a branch tip that has moved since. - ref: ${{ github.event.inputs.tag || github.ref }} + # 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 @@ -57,8 +62,10 @@ jobs: 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 }}" - python -c "import sys; sys.path.insert(0, 'src'); from offloader._version import __version__; print('version=' + __version__)" >> "$GITHUB_OUTPUT" - name: Install NSIS run: choco install nsis --version=3.12.0 -y --no-progress @@ -94,9 +101,11 @@ jobs: draft: name: Prepare the draft release needs: candidate - # Only on a real tag: a dispatch run is a rehearsal of the checks and - # should not touch releases. - if: startsWith(github.ref, 'refs/tags/v') + # 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 @@ -114,18 +123,26 @@ jobs: 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 --title "Offloader $VERSION" \ - --notes-file release-notes.md + gh release edit "$TAG" --draft --prerelease="$PRERELEASE" \ + --title "Offloader $VERSION" --notes-file release-notes.md else - gh release create "$TAG" --draft --prerelease \ + gh release create "$TAG" --draft --prerelease="$PRERELEASE" \ --title "Offloader $VERSION" --notes-file release-notes.md \ --target "$GITHUB_SHA" fi diff --git a/docs/build-windows.md b/docs/build-windows.md index 6a1ff1e..633f917 100644 --- a/docs/build-windows.md +++ b/docs/build-windows.md @@ -153,7 +153,15 @@ 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** -prerelease pinned to the tagged commit, with notes and no assets. +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 @@ -168,11 +176,23 @@ gh release upload v0.1.0b1 dist\windows\Offloader-Setup-0.1.0b1.exe dist\windows ``` `workflow_dispatch` runs the same checks without touching releases, for -rehearsing a tag before it exists. Re-running a tag refreshes the draft's notes -rather than recreating it, so a signed asset already uploaded is not discarded. +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. +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 diff --git a/scripts/check_tag.py b/scripts/check_tag.py index e99d71a..9ee417b 100644 --- a/scripts/check_tag.py +++ b/scripts/check_tag.py @@ -17,6 +17,8 @@ from __future__ import annotations import argparse +import os +import re import sys from pathlib import Path @@ -29,6 +31,10 @@ #: 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.""" @@ -39,6 +45,27 @@ def version_from_tag(tag: str) -> str: 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") @@ -56,7 +83,9 @@ def main(argv: list[str] | None = None) -> int: f"src/offloader/_version.py declares {__version__!r}. Bump the " f"version literal and commit before tagging.", file=sys.stderr) return 1 - print(f"tag {args.tag} matches the declared version {__version__}") + _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 diff --git a/tests/test_check_tag.py b/tests/test_check_tag.py index c2b72e0..f23239a 100644 --- a/tests/test_check_tag.py +++ b/tests/test_check_tag.py @@ -56,6 +56,44 @@ def test_a_tag_that_is_not_a_release_version_fails(tag: str): assert _gate().main([tag]) == 2 +@pytest.mark.parametrize("version,prerelease", [ + ("0.1.0", False), + ("1.0.0", False), + ("10.20.30", False), + ("0.1.0a1", True), + ("0.1.0b2", True), + ("0.1.0rc1", True), +]) +def test_a_version_is_classified_for_the_draft(version: str, prerelease: bool): + """The draft's prerelease flag is derived from this. 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 go on declining + the release meant for them.""" + assert _gate().is_prerelease(version) is prerelease + + +def test_the_gate_hands_the_workflow_both_facts(tmp_path, monkeypatch): + """Written by the gate that validated the tag, rather than read a second + time by a step that could disagree with it.""" + output = tmp_path / "github_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + assert _gate().main([f"v{__version__}"]) == 0 + + written = dict(line.split("=", 1) + for line in output.read_text(encoding="utf-8").splitlines()) + assert written["version"] == __version__ + assert written["prerelease"] in ("true", "false") + assert (written["prerelease"] == "true") is _gate().is_prerelease(__version__) + + +def test_a_refused_tag_writes_no_outputs(tmp_path, monkeypatch): + """A gate that failed must not leave a later step a version to build with.""" + output = tmp_path / "github_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + assert _gate().main(["v99.0.0"]) == 1 + assert not output.exists() + + def test_the_gate_uses_one_grammar_with_the_updater(): """If these ever diverge, a tag could pass the gate and then be invisible to the updater, or vice versa.""" diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index d9a3a95..68f2061 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -102,12 +102,79 @@ def test_the_draft_job_waits_for_the_candidate(workflow): assert workflow["jobs"]["draft"]["needs"] == "candidate" -def test_a_rehearsal_run_does_not_touch_releases(workflow): - """`workflow_dispatch` exists to exercise the checks. Guarded by a ref - condition so a manual run cannot create a draft for a tag that does not - exist.""" +def _evaluate(condition: str, **context: str) -> bool: + """The slice of GitHub's expression syntax this workflow uses. + + Written out rather than pattern-matched on the condition text, because the + defect here was a condition that read correctly and was true in a case + nobody had enumerated. Three events, one of them allowed. + """ + def value(token: str) -> str: + token = token.strip() + if token.startswith("'") and token.endswith("'"): + return token[1:-1] + return context[token] + + for clause in (part.strip() for part in condition.split("&&")): + if clause.startswith("startsWith(") and clause.endswith(")"): + left, right = clause[len("startsWith("):-1].split(",") + if not value(left).startswith(value(right)): + return False + elif "==" in clause: + left, right = clause.split("==") + if value(left) != value(right): + return False + else: + raise AssertionError(f"unsupported condition clause: {clause!r}") + return True + + +@pytest.mark.parametrize("event,ref,allowed", [ + ("push", "refs/tags/v0.1.0b1", True), + ("workflow_dispatch", "refs/heads/main", False), + ("workflow_dispatch", "refs/tags/v0.1.0b1", False), +]) +def test_only_a_pushed_tag_may_mutate_a_release(workflow, event, ref, allowed): + """REGRESSION. The ref test alone let the third case through: a dispatch + can be started against an existing tag, and `github.ref` is a tag ref then + too. The rehearsal took the write token and edited the release, including + passing `--draft` to one that had been published.""" condition = workflow["jobs"]["draft"]["if"] - assert "refs/tags/v" in condition + assert _evaluate(condition, **{"github.event_name": event, + "github.ref": ref}) is allowed + + +def test_a_rehearsal_checks_out_the_commit_it_was_started_from(workflow): + """REGRESSION. The dispatch input was also used as the checkout ref, so + entering a proposed tag that has no ref yet failed in checkout before + `check_tag.py` could validate it -- which is the whole documented purpose + of the rehearsal.""" + checkout = next(step for step in workflow["jobs"]["candidate"]["steps"] + if str(step.get("uses", "")).startswith("actions/checkout")) + assert "inputs.tag" not in checkout["with"]["ref"] + assert "github.ref" in checkout["with"]["ref"] + + +def test_the_proposed_tag_still_reaches_the_version_gate(workflow): + """The other half: not using it as a ref must not mean ignoring it.""" + gate = next(step for step in workflow["jobs"]["candidate"]["steps"] + if "check_tag.py" in step.get("run", "")) + assert "inputs.tag" in gate["run"] + + +def test_the_draft_classification_comes_from_the_gated_version(workflow): + """REGRESSION. `--prerelease` was passed unconditionally, so a stable tag + published a prerelease. GitHub excludes those from `/releases/latest`, + which is the feed the updater reads, so every installed copy would decline + the release meant for them.""" + assert workflow["jobs"]["candidate"]["outputs"]["prerelease"] + step = next(step for _job, step in _steps(workflow) + if "gh release create" in step.get("run", "")) + assert "--prerelease=\"$PRERELEASE\"" in step["run"] + # Both paths, so a rerun after a version change corrects an existing + # draft rather than inheriting whatever the first run chose. + assert step["run"].count('--prerelease="$PRERELEASE"') == 2 + assert step["env"]["PRERELEASE"] == "${{ needs.candidate.outputs.prerelease }}" # ------------------------------------------------------------------ the notes From c8d94af5a7b68e7327ff50dcdb2c2bc857f5370a Mon Sep 17 00:00:00 2001 From: owenpkent <20529132+owenpkent@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:38:46 -0400 Subject: [PATCH 3/3] Record the workflow corrections in the changelog --- CHANGELOG.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb384a..875d2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ project uses [semantic versioning][semver]. 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 prerelease pinned to the tagged commit. It attaches no + 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 @@ -24,6 +24,19 @@ project uses [semantic versioning][semver]. 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