diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..74e2fb9 --- /dev/null +++ b/.github/workflows/release.yml @@ -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." diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8e5d5..875d2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/build-windows.md b/docs/build-windows.md index b944711..633f917 100644 --- a/docs/build-windows.md +++ b/docs/build-windows.md @@ -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, 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..9ee417b --- /dev/null +++ b/scripts/check_tag.py @@ -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()) 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..f23239a --- /dev/null +++ b/tests/test_check_tag.py @@ -0,0 +1,105 @@ +"""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 + + +@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.""" + 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..68f2061 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,206 @@ +"""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 _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 _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 + + +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")