From f04ae032d78def80bf788993b7e302ef719bc476 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 4 Oct 2026 12:40:05 +0000 Subject: [PATCH] fix: stage verified release drafts without public asset replacement --- .github/workflows/release.yml | 57 +++--- RELEASE.md | 58 +++++- docs/PACKAGING.md | 71 ++++--- scripts/stage-draft-release.py | 169 ++++++++++++++++ scripts/test_stage_draft_release.py | 296 ++++++++++++++++++++++++++++ 5 files changed, 591 insertions(+), 60 deletions(-) create mode 100644 scripts/stage-draft-release.py create mode 100644 scripts/test_stage_draft_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0199ea1..699bdb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,15 +4,19 @@ on: workflow_dispatch: inputs: release_tag: - description: Existing release tag to receive rebuilt packages (leave blank for a rehearsal) + description: Existing tag to stage as a verified draft (leave blank for a rehearsal) required: false type: string - release: - types: [published] permissions: contents: read +# Never run two writers for the same draft concurrently. Publication is a +# separate, explicit draft-to-public transition and does not trigger a build. +concurrency: + group: native-release-${{ inputs.release_tag || github.ref }} + cancel-in-progress: false + jobs: browser-matrix: name: Browser matrix (Linux) @@ -22,7 +26,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + ref: ${{ github.sha }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 @@ -75,7 +79,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + ref: ${{ github.sha }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -86,7 +90,7 @@ jobs: - name: Verify release version shell: bash env: - CYBERSNAPPER_RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} + CYBERSNAPPER_RELEASE_TAG: ${{ inputs.release_tag }} run: node scripts/check-release-version.mjs "$CYBERSNAPPER_RELEASE_TAG" - uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4.4.1 @@ -213,7 +217,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + ref: ${{ github.sha }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -224,7 +228,7 @@ jobs: - name: Verify release version shell: bash env: - CYBERSNAPPER_RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} + CYBERSNAPPER_RELEASE_TAG: ${{ inputs.release_tag }} run: node scripts/check-release-version.mjs "$CYBERSNAPPER_RELEASE_TAG" - uses: ilammy/msvc-dev-cmd@v1 @@ -340,7 +344,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + ref: ${{ github.sha }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -351,7 +355,7 @@ jobs: - name: Verify release version shell: bash env: - CYBERSNAPPER_RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }} + CYBERSNAPPER_RELEASE_TAG: ${{ inputs.release_tag }} run: node scripts/check-release-version.mjs "$CYBERSNAPPER_RELEASE_TAG" - uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4.4.1 @@ -477,7 +481,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + ref: ${{ github.sha }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: CyberSnapper-linux-x64 @@ -527,7 +531,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + ref: ${{ github.sha }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: CyberSnapper-linux-x64 @@ -577,7 +581,7 @@ jobs: xvfb.log publish: - name: Assemble, verify, and optionally publish + name: Assemble, verify, and optionally stage draft needs: [linux, windows, macos, appimage-compatibility, appimage-catalog] runs-on: ubuntu-latest permissions: @@ -588,7 +592,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag || github.ref }} + ref: ${{ github.sha }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: artifacts @@ -599,26 +603,33 @@ jobs: run: python3 scripts/check-release-assets.py artifacts - name: Preserve verified release rehearsal - if: github.event_name == 'workflow_dispatch' && inputs.release_tag == '' + if: inputs.release_tag == '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: CyberSnapper-release-rehearsal path: artifacts/* if-no-files-found: error + - name: Verify exact tag and draft before attesting + if: inputs.release_tag != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_COMMIT: ${{ github.sha }} + run: python3 scripts/stage-draft-release.py artifacts --tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT" --check-only + - name: Attest package provenance - if: github.event_name == 'release' || inputs.release_tag != '' + if: inputs.release_tag != '' uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: artifacts/* - - name: Publish packages to release - if: github.event_name == 'release' || inputs.release_tag != '' - shell: bash + - name: Stage complete verified draft without replacing assets + if: inputs.release_tag != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - run: | - set -euo pipefail - tag="${{ github.event_name == 'release' && github.event.release.tag_name || inputs.release_tag }}" - gh release upload "$tag" artifacts/* --clobber + RELEASE_TAG: ${{ inputs.release_tag }} + RELEASE_COMMIT: ${{ github.sha }} + run: python3 scripts/stage-draft-release.py artifacts --tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT" diff --git a/RELEASE.md b/RELEASE.md index 93505e9..f2713b4 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -26,15 +26,40 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup 3. Create an annotated tag on that commit, for example `git tag -a v2.2.2 -m "CyberSnapper 2.2.2"`, and push the tag. 4. Treat a pushed release tag as immutable. Never move or replace a published `v*` tag; ship a new patch version if tagged source needs a code change. -## 4. Publish - -1. Create a draft GitHub release for the existing tag and use the matching `docs/releases/` file as its release notes. -2. Publish the release. The `release: published` event starts **Native Release**, which builds from the release tag and attaches all packages, checksums, and attestations. -3. Do not use a nonblank manual `release_tag` for the normal publication path; it is reserved for recovery of an existing release. +## 4. Stage verified packages, then publish + +1. Dispatch **Native Release** at the exact tag, with that same tag as the input: + + ```bash + gh workflow run release.yml --ref vMAJOR.MINOR.PATCH -f release_tag=vMAJOR.MINOR.PATCH + ``` + + All candidate checkouts use the dispatch's immutable commit SHA. The tag, + workflow ref, checked-out source, and declared version must agree. Dispatching + a branch with a different `release_tag` is rejected. +2. Wait for every native package, browser, runtime, catalog, and assembly gate. + The final job validates all fourteen assets and checksums, creates provenance + attestations, then creates or resumes a **draft** release using the committed + notes. It never publishes a release, overwrites an asset, or deletes a file. +3. Require successful staging and independently confirm the draft contains all + fifteen files (fourteen assets plus `SHA256SUMS.txt`). Every remote file must + be uploaded and match the checked local size and SHA-256 digest. Verify the + tag still resolves to the rehearsed commit and provenance identifies it. +4. Do not publish while any staging run is active. Workflow concurrency + serializes staging runs for a tag, but cannot lock out a maintainer publishing + from another session. After all checks pass, explicitly publish the complete + draft: + + ```bash + gh release edit vMAJOR.MINOR.PATCH --draft=false --latest + ``` + + For prereleases, keep the prerelease flag and do not mark the release latest. + Publication does **not** trigger a rebuild or replace the verified files. ## 5. Verify before announcing -1. Require the complete release workflow to pass, including all six native builds and the publish job. +1. Require the complete release workflow to pass, including all six native builds and the draft-staging job. 2. Confirm the release contains all of the following, plus `SHA256SUMS.txt`, with provenance attestations visible in GitHub: - `CyberSnapper-linux-x64.AppImage`, its `.zsync` sidecar, and `CyberSnapper-linux-x64.tar.gz` @@ -51,10 +76,23 @@ The workflow creates AppImage and tar.gz packages for Linux x64 and arm64, setup ## Recovery -- For a transient runner or upload failure, rerun the failed release-workflow jobs against the same tag. -- If valid packages were built but assets were not attached correctly, manually dispatch **Native Release** with the existing `release_tag`; verify the rebuilt assets and checksums again. -- If the tagged source or a packaged application is defective, do not move the tag or silently replace the release. Document the issue and publish a corrected patch release from a new commit and tag. -- Keep an incomplete release unannounced until recovery succeeds. If downloads may be unsafe or misleading, mark the release as a prerelease while preparing the corrective release. +- A failed build leaves the public release unchanged. Fix any source defect in a + new candidate commit and repeat CI and the full rehearsal before tagging. +- For a transient draft-upload failure, rerun only the failed assembly/staging + job of the same workflow run. Its retained platform artifacts preserve the + exact bytes. Matching existing draft assets are retained and missing files + are uploaded; a full rebuild may produce different package bytes and is not + a substitute for resuming those artifacts. +- A conflicting, incomplete (`starter`), zero-byte, duplicate, unexpected, or + digest-less remote asset blocks staging. Nothing is deleted automatically. + Inspect the draft and obtain explicit approval for any destructive cleanup; + never publish a partial draft to work around a failure. +- Published releases are read-only to this workflow. If tagged source or a + published package is defective, document the issue and ship a corrected patch + version from a new commit and tag. Do not move a published `v*` tag or silently + overwrite its assets. +- Keep workflow artifacts and validation evidence until public download and + provenance verification are complete. Never announce a release before that. ## v2.4.2 diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 64bb78b..178de52 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -1,14 +1,15 @@ # Packaging and publishing This document describes *how* CyberSnapper's release packages are built and -published. For the step-by-step release *process* (rehearse → tag → publish → -verify), see [RELEASE.md](../RELEASE.md). For local builds and the install +published. For the step-by-step release *process* (rehearse → tag → stage → +publish → verify), see [RELEASE.md](../RELEASE.md). For local builds and the install tree, see [BUILDING.md](BUILDING.md). Everything runs in one workflow: `.github/workflows/release.yml` ("Native Release"). Each package is produced on a GitHub Actions runner for its target -platform, smoke-tested end to end, then attached to the GitHub release together -with checksums and provenance attestations. +platform, smoke-tested end to end, then staged on a draft GitHub release together +with checksums and provenance attestations. Publication is a separate explicit +action after the complete draft passes verification. ## Package matrix @@ -34,20 +35,23 @@ The workflow has native package jobs and independent validation gates: - `macos` — matrix over x64 and arm64. - `appimage-compatibility` — audits the exact x64 AppImage and opens its GUI on Ubuntu 22.04 and 24.04 without a Qt SDK. - `appimage-catalog` — runs the pinned upstream AppImageHub worker on the exact x64 candidate. -- `publish` — waits for every package and compatibility gate, validates the complete asset set, then preserves a rehearsal bundle or publishes an authorized release. +- `publish` — waits for every package and compatibility gate, validates the complete asset set, then preserves a rehearsal bundle or stages an attested draft. -Triggers: +Trigger: -- `release: [published]` — builds from the published release tag and attaches - the packages to that release. This is the normal publication path. -- `workflow_dispatch` with an optional `release_tag` input — a blank tag is a - *rehearsal* (packages are uploaded as workflow artifacts, no release is - touched); a nonblank tag is the *recovery* path for rebuilding and - re-attaching an existing release. +- `workflow_dispatch` with an optional `release_tag` input. A blank tag is a + rehearsal: packages are uploaded as workflow artifacts, no release is touched. +- A nonblank tag stages packages on a draft only. Dispatch at that exact tag, + for example `gh workflow run release.yml --ref v2.4.2 -f release_tag=v2.4.2`. + The final job rejects a mismatched workflow ref, commit, tag, or version. +- There is no `release: published` trigger. Publishing an already verified draft + neither rebuilds nor overwrites its assets. -The `checkout` step selects `ref` from the release tag, the dispatch tag, or -the branch — in that order. The workflow definition itself is loaded from the -dispatched branch, while the built source comes from the selected ref. +Every candidate checkout uses `github.sha`, including the independent runtime, +catalog, and assembly gates. The catalog worker retains its separate pinned +upstream commit. Staging runs for one tag are serialized without canceling an +active run. Maintainers must wait for successful staging before publishing; +GitHub has no atomic "upload only if still draft" operation. ## Common build steps @@ -170,16 +174,27 @@ from the runner's full Qt install. Because this step runs before `upload-artifact`, a package that cannot actually launch Chromium never reaches the release. -## Publishing +## Draft staging and publication When all platform jobs succeed and a release tag is in play, the `publish` job: -1. Downloads all uploaded artifacts (merged into one directory). -2. Requires exactly twelve nonempty native packages plus two matching AppImage zsync sidecars, then writes `SHA256SUMS.txt` from all fourteen assets. -3. Records a GitHub/Sigstore build-provenance attestation over the artifacts. -4. Uploads everything to the release with - `gh release upload "$tag" artifacts/* --clobber`. The job has no checkout, - so it sets `GH_REPO` to point `gh` at the repository explicitly. +1. Downloads the exact run's platform artifacts into one directory. +2. Requires exactly twelve nonempty native packages plus two matching AppImage + zsync sidecars, then writes `SHA256SUMS.txt` from all fourteen assets. +3. Verifies the exact dispatch ref/commit, source version, tag target, local + checksums and sidecars, and any existing draft assets before attesting. +4. Records GitHub/Sigstore build-provenance attestations over all fifteen files. +5. Runs `scripts/stage-draft-release.py` with the same tag and commit. The helper + creates or resumes a draft, uploads only missing files without `--clobber`, + and checks every remote name, state, size, and SHA-256 digest. It rechecks + draft status before each upload and after verification. It refuses published + releases, conflicting/incomplete assets, and a moved tag. + +The job retains its existing `contents: write`, `id-token: write`, and +`attestations: write` permissions; it needs no additional secret or token scope. +Nothing in the workflow publishes a release. A maintainer explicitly changes +only the verified draft's publication state after the run succeeds; the same +attested bytes then become public. See [RELEASE.md](../RELEASE.md). ## Signing @@ -191,11 +206,13 @@ download. No paid signing identity is required. ## Recovery -If a package job fails after a tag is already published, re-dispatch the -workflow with the same nonblank `release_tag`; the build jobs check out that -tag and the publish job re-attaches with `--clobber`. Do not move a published -`v*` tag — ship a new patch version if tagged source needs a code change. See -the Recovery section of [RELEASE.md](../RELEASE.md) for the full policy. +Resume a failed draft-staging job within the same run to reuse its exact retained +artifacts. Matching files are skipped; missing files are uploaded. A conflicting +or incomplete remote asset fails safely without deletion, so any cleanup needs +explicit review. Do not rerun packaging and assume its bytes will be identical, +and do not publish while staging is active. Published assets cannot be changed +by this workflow; use a new patch version for a defective public release. Never +move a published `v*` tag. See [RELEASE.md](../RELEASE.md) for the full policy. ## AppImage updates and catalog submission diff --git a/scripts/stage-draft-release.py b/scripts/stage-draft-release.py new file mode 100644 index 0000000..919a630 --- /dev/null +++ b/scripts/stage-draft-release.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Stage checked, attested packages on a draft; never publish or replace assets.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.parse import quote + +spec = importlib.util.spec_from_file_location('release_assets', Path(__file__).with_name('check-release-assets.py')) +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def gh(*args): + return subprocess.run(['gh', *args], check=True, text=True, capture_output=True).stdout + + +def api(path): + return json.loads(gh('api', path)) + + +def manifest(directory): + expected = checker.expected_assets() | {'SHA256SUMS.txt'} + require({path.name for path in directory.iterdir()} == expected, 'Incomplete or unexpected local release assets') + result = {} + for name in sorted(expected): + path = directory / name + require(path.is_file() and not path.is_symlink() and path.stat().st_size > 0, + f'Missing, empty, or symlinked asset: {name}') + digest = hashlib.sha256() + with path.open('rb') as source: + for chunk in iter(lambda: source.read(1024 * 1024), b''): + digest.update(chunk) + result[name] = {'size': path.stat().st_size, 'digest': 'sha256:' + digest.hexdigest()} + expected_sums = ''.join(f'{result[name]["digest"][7:]} {name}\n' + for name in sorted(checker.expected_assets())) + require((directory / 'SHA256SUMS.txt').read_text() == expected_sums, + 'SHA256SUMS.txt does not match the exact local package bytes') + for arch in ('x64', 'arm64'): + checker.checker.verify_sidecar(directory / f'CyberSnapper-linux-{arch}.AppImage') + return result + + +def tag_commit(repo, tag): + target = api(f'repos/{repo}/git/ref/tags/{quote(tag, safe="")}')['object'] + for _ in range(10): + if target['type'] == 'commit': + return target['sha'] + require(target['type'] == 'tag', 'Release tag does not identify a commit') + target = api(f'repos/{repo}/git/tags/{target["sha"]}')['object'] + raise ValueError('Too many nested annotated tags') + + +def list_api(path): + # JSON Lines works with older gh versions too; do not depend on --slurp. + return [json.loads(line) for line in gh('api', '--paginate', path, '--jq', '.[] | @json').splitlines() if line] + + +def find_release(repo, tag): + # List includes drafts with this authenticated token. Do not treat a failed + # lookup as absence or silently fall back after any API/authentication error. + releases = list_api(f'repos/{repo}/releases?per_page=100') + matches = [release for release in releases if release['tag_name'] == tag] + require(len(matches) <= 1, 'Multiple releases refer to this tag') + return matches[0] if matches else None + + +def check_draft(release, tag): + require(release['tag_name'] == tag, 'Release tag changed') + require(release.get('draft') is True and not release.get('published_at'), + 'Refusing to modify a published release') + require(bool(release.get('prerelease')) == ('-' in tag), 'Draft prerelease status does not match the tag') + + +def check_remote(assets, expected, complete=False): + names = [asset['name'] for asset in assets] + require(len(names) == len(set(names)), 'Duplicate remote asset names') + require(set(names) <= set(expected), 'Unexpected remote release assets') + for asset in assets: + name = asset['name'] + require(asset.get('state') == 'uploaded' and + asset.get('size') == expected[name]['size'] and + asset.get('digest') == expected[name]['digest'], + f'Conflicting or incomplete remote asset: {name}; existing assets are never replaced') + if complete: + require(set(names) == set(expected), 'Draft does not contain the complete verified asset set') + return set(names) + + +def preflight(directory, tag, commit, repo): + require(re.fullmatch(r'[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', repo or ''), 'GH_REPO must name owner/repository') + require(re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?', tag), 'Invalid release tag') + require(re.fullmatch(r'[0-9a-f]{40}', commit), 'Expected an exact release commit SHA') + # The workflow definition, checkout and provenance must all use the same tag. + require(os.environ.get('GITHUB_REF') == f'refs/tags/{tag}', 'Dispatch the workflow at the exact release tag') + require(os.environ.get('GITHUB_SHA') == commit, 'Workflow commit does not match the expected release commit') + require(gh('api', f'repos/{repo}/commits/{commit}', '--jq', '.sha').strip() == commit, + 'Release commit is not available in the repository') + require(tag_commit(repo, tag) == commit, 'Release tag does not point to the built commit') + version = json.loads(Path('package.json').read_text())['version'] + require(tag == f'v{version}', 'Release tag does not match the checked-out source version') + require(subprocess.run(['git', 'rev-parse', 'HEAD'], check=True, text=True, + capture_output=True).stdout.strip() == commit, + 'Checked-out source is not the workflow commit') + expected = manifest(directory) + release = find_release(repo, tag) + if release is not None: + check_draft(release, tag) + check_remote(list_api(f'repos/{repo}/releases/{release["id"]}/assets?per_page=100'), expected) + return expected, version, release + + +def stage(directory, tag, commit, repo): + expected, version, release = preflight(directory, tag, commit, repo) + if release is None: + notes = Path('docs/releases') / f'{tag}.md' + require(notes.is_file(), 'Missing committed release notes') + flags = ['--prerelease'] if '-' in version else [] + gh('release', 'create', tag, '--repo', repo, '--verify-tag', '--draft', + '--title', f'CyberSnapper {version}', '--notes-file', str(notes), *flags) + release = find_release(repo, tag) + require(release is not None, 'Created draft could not be read back') + check_draft(release, tag) + release_id = release['id'] + for name in sorted(expected): + # Re-read before every upload and never use --clobber or delete an asset. + release = api(f'repos/{repo}/releases/{release_id}') + check_draft(release, tag) + present = check_remote(list_api(f'repos/{repo}/releases/{release_id}/assets?per_page=100'), expected) + if name not in present: + gh('release', 'upload', tag, str(directory / name), '--repo', repo) + release = api(f'repos/{repo}/releases/{release_id}') + check_draft(release, tag) + check_remote(list_api(f'repos/{repo}/releases/{release_id}/assets?per_page=100'), expected, complete=True) + require(tag_commit(repo, tag) == commit, 'Release tag moved while staging') + print(f'Verified draft {tag}: {len(expected)} assets match {commit}. Publication remains an explicit maintainer action.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('directory', type=Path) + parser.add_argument('--tag', required=True) + parser.add_argument('--commit', required=True) + parser.add_argument('--check-only', action='store_true', help='Validate without creating or uploading anything') + args = parser.parse_args() + try: + if args.check_only: + preflight(args.directory, args.tag, args.commit, os.environ.get('GH_REPO')) + print('Exact release tag, source, local assets, and draft target verified.') + else: + stage(args.directory, args.tag, args.commit, os.environ.get('GH_REPO')) + except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as error: + print(f'Draft staging failed: {error}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/test_stage_draft_release.py b/scripts/test_stage_draft_release.py new file mode 100644 index 0000000..18c6f4f --- /dev/null +++ b/scripts/test_stage_draft_release.py @@ -0,0 +1,296 @@ +"""Offline regression tests for non-destructive, draft-only release staging.""" +import copy +import hashlib +import importlib.util +import json +import os +import re +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('stage_draft', ROOT / 'scripts/stage-draft-release.py') +stager = importlib.util.module_from_spec(spec) +spec.loader.exec_module(stager) +SHA = 'a' * 40 +TAG = 'v' + json.loads((ROOT / 'package.json').read_text())['version'] +REPO = 'example/CyberSnapper' +DRAFT = {'id': 42, 'tag_name': TAG, 'draft': True, 'published_at': None} + + +def remote_assets(expected): + return [dict(name=name, state='uploaded', **metadata) for name, metadata in expected.items()] + + +class ManifestTest(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.directory = Path(temporary.name) + for name in stager.checker.expected_assets(): + (self.directory / name).write_bytes(b'release fixture') + for arch in ('x64', 'arm64'): + image = self.directory / f'CyberSnapper-linux-{arch}.AppImage' + fields = {'zsync': '0.6.2', 'Filename': image.name, 'URL': image.name, + 'Length': str(image.stat().st_size), 'SHA-1': hashlib.sha1(image.read_bytes()).hexdigest(), + 'Blocksize': '2048', 'Hash-Lengths': '1,2,4'} + image.with_name(image.name + '.zsync').write_bytes( + ('\n'.join(f'{key}: {value}' for key, value in fields.items()) + '\n\n').encode() + b'123456') + stager.checker.verify(self.directory) + + def test_all_fifteen_exact_assets(self): + result = stager.manifest(self.directory) + self.assertEqual(len(result), 15) + self.assertIn('SHA256SUMS.txt', result) + self.assertTrue(all(x['digest'].startswith('sha256:') for x in result.values())) + + def test_missing_sidecar(self): + (self.directory / 'CyberSnapper-linux-x64.AppImage.zsync').unlink() + with self.assertRaisesRegex(ValueError, 'Incomplete'): + stager.manifest(self.directory) + + def test_extra_file(self): + (self.directory / 'log.txt').write_text('not a release asset') + with self.assertRaisesRegex(ValueError, 'unexpected'): + stager.manifest(self.directory) + + def test_stale_checksums_are_not_rewritten(self): + sums = self.directory / 'SHA256SUMS.txt' + sums.write_text('invalid\n') + with self.assertRaisesRegex(ValueError, 'exact local package bytes'): + stager.manifest(self.directory) + self.assertEqual(sums.read_text(), 'invalid\n') + + def test_checksum_symlink_rejected(self): + sums = self.directory / 'SHA256SUMS.txt' + original = self.directory.parent / (self.directory.name + '-sums') + original.write_text(sums.read_text()) + self.addCleanup(original.unlink) + sums.unlink() + sums.symlink_to(original) + with self.assertRaisesRegex(ValueError, 'symlinked'): + stager.manifest(self.directory) + + +class RemoteAssetsTest(unittest.TestCase): + def setUp(self): + self.expected = {'asset': {'size': 3, 'digest': 'sha256:' + 'a' * 64}} + + def test_complete_matching_assets(self): + self.assertEqual(stager.check_remote(remote_assets(self.expected), self.expected, True), {'asset'}) + + def test_partial_matching_assets_allowed_only_during_upload(self): + self.assertEqual(stager.check_remote([], self.expected), set()) + with self.assertRaisesRegex(ValueError, 'complete'): + stager.check_remote([], self.expected, True) + + def test_reject_duplicate_names(self): + with self.assertRaisesRegex(ValueError, 'Duplicate'): + stager.check_remote(remote_assets(self.expected) * 2, self.expected) + + def test_reject_unexpected_names(self): + with self.assertRaisesRegex(ValueError, 'Unexpected'): + stager.check_remote([{'name': 'surprise'}], self.expected) + + def test_reject_starter_missing_digest_wrong_size_and_wrong_digest(self): + for change in ({'state': 'starter'}, {'digest': None}, {'size': 0}, {'digest': 'sha256:bad'}): + with self.subTest(change=change): + assets = remote_assets(self.expected) + assets[0].update(change) + with self.assertRaisesRegex(ValueError, 'never replaced'): + stager.check_remote(assets, self.expected) + + +class IdentityTest(unittest.TestCase): + def setUp(self): + self.env = patch.dict(os.environ, {'GITHUB_REF': f'refs/tags/{TAG}', 'GITHUB_SHA': SHA}) + self.env.start() + self.addCleanup(self.env.stop) + + def preflight(self): + with patch.object(stager, 'gh', return_value=SHA + '\n'), \ + patch.object(stager, 'tag_commit', return_value=SHA), \ + patch.object(stager, 'manifest', return_value={}), \ + patch.object(stager, 'find_release', return_value=None), \ + patch.object(stager.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, SHA + '\n')): + return stager.preflight(Path('unused'), TAG, SHA, REPO) + + def test_matching_exact_tag_and_commit(self): + self.assertEqual(self.preflight()[2], None) + + def test_branch_dispatch_rejected(self): + os.environ['GITHUB_REF'] = 'refs/heads/master' + with self.assertRaisesRegex(ValueError, 'exact release tag'): + self.preflight() + + def test_wrong_workflow_commit_rejected(self): + os.environ['GITHUB_SHA'] = 'b' * 40 + with self.assertRaisesRegex(ValueError, 'Workflow commit'): + self.preflight() + + def test_lightweight_tag(self): + with patch.object(stager, 'api', return_value={'object': {'type': 'commit', 'sha': SHA}}): + self.assertEqual(stager.tag_commit(REPO, TAG), SHA) + + def test_annotated_tag(self): + with patch.object(stager, 'api', side_effect=[{'object': {'type': 'tag', 'sha': 'b' * 40}}, + {'object': {'type': 'commit', 'sha': SHA}}]): + self.assertEqual(stager.tag_commit(REPO, TAG), SHA) + + def test_tag_tree_rejected(self): + with patch.object(stager, 'api', return_value={'object': {'type': 'tree', 'sha': SHA}}): + with self.assertRaisesRegex(ValueError, 'does not identify a commit'): + stager.tag_commit(REPO, TAG) + + def test_paginated_json_lines(self): + with patch.object(stager, 'gh', return_value='{"name":"one"}\n{"name":"two"}\n') as cli: + self.assertEqual(len(stager.list_api('path')), 2) + self.assertIn('--paginate', cli.call_args.args) + + def test_release_lookup_failure_is_not_absence(self): + with patch.object(stager, 'list_api', side_effect=subprocess.CalledProcessError(1, ['gh'])): + with self.assertRaises(subprocess.CalledProcessError): + stager.find_release(REPO, TAG) + + +class StagingTest(unittest.TestCase): + def setUp(self): + self.expected = {'one': {'size': 1, 'digest': 'sha256:1'}, 'two': {'size': 2, 'digest': 'sha256:2'}} + self.assets = [] + self.release = copy.deepcopy(DRAFT) + self.commands = [] + + def cli(self, *args): + self.commands.append(args) + if args[:2] == ('release', 'upload'): + name = Path(args[3]).name + self.assets.append(dict(name=name, state='uploaded', **self.expected[name])) + return '' + + def stage(self, release=None): + with patch.object(stager, 'preflight', return_value=(self.expected, TAG[1:], release or self.release)), \ + patch.object(stager, 'gh', side_effect=self.cli), \ + patch.object(stager, 'api', side_effect=lambda _: copy.deepcopy(self.release)), \ + patch.object(stager, 'list_api', side_effect=lambda _: copy.deepcopy(self.assets)), \ + patch.object(stager, 'tag_commit', return_value=SHA): + stager.stage(Path('artifacts'), TAG, SHA, REPO) + + def test_uploads_missing_assets_without_clobber_or_publication(self): + self.stage() + self.assertEqual({a['name'] for a in self.assets}, set(self.expected)) + self.assertEqual(len(self.commands), 2) + self.assertTrue(all(command[:2] == ('release', 'upload') for command in self.commands)) + self.assertNotIn('--clobber', str(self.commands)) + + def test_identical_retry_does_not_upload(self): + self.assets = remote_assets(self.expected) + self.stage() + self.assertEqual(self.commands, []) + + def test_published_release_is_never_modified(self): + self.release.update(draft=False, published_at='2026-10-04T12:00:00Z') + with self.assertRaisesRegex(ValueError, 'published'): + self.stage() + self.assertEqual(self.commands, []) + + def test_publish_during_staging_stops_further_uploads(self): + original = self.cli + def publishing_cli(*args): + result = original(*args) + self.release.update(draft=False, published_at='2026-10-04T12:00:00Z') + return result + self.cli = publishing_cli + with self.assertRaisesRegex(ValueError, 'published'): + self.stage() + self.assertEqual(len(self.commands), 1) + + def test_conflicting_existing_asset_is_preserved(self): + self.assets = [{'name': 'one', 'state': 'uploaded', 'size': 10, 'digest': 'sha256:other'}] + before = copy.deepcopy(self.assets) + with self.assertRaisesRegex(ValueError, 'never replaced'): + self.stage() + self.assertEqual(self.commands, []) + self.assertEqual(self.assets, before) + + + def test_creates_only_a_draft_and_verifies_readback(self): + with patch.object(stager, 'preflight', return_value=(self.expected, TAG[1:], None)), \ + patch.object(stager, 'find_release', return_value=self.release) as lookup, \ + patch.object(stager, 'gh', side_effect=self.cli), \ + patch.object(stager, 'api', side_effect=lambda _: copy.deepcopy(self.release)), \ + patch.object(stager, 'list_api', side_effect=lambda _: copy.deepcopy(self.assets)), \ + patch.object(stager, 'tag_commit', return_value=SHA): + stager.stage(Path('artifacts'), TAG, SHA, REPO) + create = self.commands[0] + self.assertEqual(create[:3], ('release', 'create', TAG)) + for flag in ('--draft', '--verify-tag', '--notes-file'): + self.assertIn(flag, create) + self.assertNotIn('--latest', create) + lookup.assert_called_once_with(REPO, TAG) + self.assertEqual(len(self.assets), 2) + + def test_failed_create_readback_stops_before_upload(self): + with patch.object(stager, 'preflight', return_value=(self.expected, TAG[1:], None)), \ + patch.object(stager, 'find_release', return_value=None), \ + patch.object(stager, 'gh', side_effect=self.cli): + with self.assertRaisesRegex(ValueError, 'could not be read back'): + stager.stage(Path('artifacts'), TAG, SHA, REPO) + self.assertEqual(len(self.commands), 1) + self.assertEqual(self.assets, []) + + def test_missing_uploaded_asset_fails_final_verification(self): + self.cli = lambda *args: self.commands.append(args) or '' + with self.assertRaisesRegex(ValueError, 'complete verified asset set'): + self.stage() + + def test_moved_tag_fails_final_verification(self): + self.assets = remote_assets(self.expected) + with patch.object(stager, 'preflight', return_value=(self.expected, TAG[1:], self.release)), \ + patch.object(stager, 'api', return_value=self.release), \ + patch.object(stager, 'list_api', return_value=self.assets), \ + patch.object(stager, 'tag_commit', return_value='b' * 40): + with self.assertRaisesRegex(ValueError, 'tag moved'): + stager.stage(Path('artifacts'), TAG, SHA, REPO) + + def test_prerelease_status_must_match(self): + with self.assertRaisesRegex(ValueError, 'prerelease status'): + stager.check_draft(dict(DRAFT, tag_name=TAG + '-rc.1'), TAG + '-rc.1') + + +class WorkflowSafetyTest(unittest.TestCase): + def setUp(self): + self.workflow = (ROOT / '.github/workflows/release.yml').read_text() + + def test_only_explicit_dispatch_no_public_release_mutations(self): + self.assertNotRegex(self.workflow, r'(?m)^ release:') + self.assertNotIn('--clobber', self.workflow) + self.assertNotIn('gh release edit', self.workflow) + self.assertIn('workflow_dispatch:', self.workflow) + self.assertIn('cancel-in-progress: false', self.workflow) + + def test_all_package_and_validation_gates_retained(self): + for job in ('browser-matrix', 'linux', 'windows', 'macos', 'appimage-compatibility', 'appimage-catalog', 'publish'): + self.assertIn(f'\n {job}:\n', self.workflow) + self.assertIn('needs: [browser-matrix]', self.workflow) + self.assertIn('needs: [linux, windows, macos, appimage-compatibility, appimage-catalog]', self.workflow) + self.assertIn('os: [ubuntu-22.04, ubuntu-24.04]', self.workflow) + self.assertEqual(len(re.findall(r'(?m)^ arch: x64$', self.workflow)), 3) + self.assertEqual(len(re.findall(r'(?m)^ arch: arm64$', self.workflow)), 3) + + def test_candidate_checkouts_use_immutable_workflow_sha(self): + # Seven candidate checkouts; the eighth checkout is a separately pinned upstream catalog. + self.assertEqual(self.workflow.count('ref: ${{ github.sha }}'), 7) + self.assertNotIn('ref: ${{ inputs.release_tag', self.workflow) + + def test_draft_preflight_then_attestation_then_staging(self): + self.assertLess(self.workflow.index('--check-only'), self.workflow.index('- name: Attest package provenance')) + self.assertLess(self.workflow.index('- name: Attest package provenance'), self.workflow.index('- name: Stage complete verified draft')) + self.assertIn('id-token: write\n attestations: write', self.workflow) + self.assertIn('subject-path: artifacts/*', self.workflow) + + +if __name__ == '__main__': + unittest.main()