diff --git a/.claude/policy-brief.md b/.claude/policy-brief.md index 08935262b..decb5c39a 100644 --- a/.claude/policy-brief.md +++ b/.claude/policy-brief.md @@ -1,7 +1,7 @@ # Loop agent policy adapter -Repository: `studio-berry/loop`; version: `0.2.0-alpha`; language: `C++20`; minimum Qt: `6.11.1`. +Repository: `studio-berry/loop`; version: `0.2.1-alpha`; language: `C++20`; minimum Qt: `6.11.1`. ## Branches and safety diff --git a/.cursor/agent-policy.md b/.cursor/agent-policy.md index 54946fb73..0a8634a67 100644 --- a/.cursor/agent-policy.md +++ b/.cursor/agent-policy.md @@ -1,7 +1,7 @@ # Loop agent policy adapter -Repository: `studio-berry/loop`; version: `0.2.0-alpha`; language: `C++20`; minimum Qt: `6.11.1`. +Repository: `studio-berry/loop`; version: `0.2.1-alpha`; language: `C++20`; minimum Qt: `6.11.1`. ## Branches and safety diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 092ff0416..186840154 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,6 +4,10 @@ +## Release changelog + + + ## Proof - [ ] `python scripts/agent/check-change.py --base origin/dev --build-dir build-local` reports `pass`, or the report and the failing check are quoted here diff --git a/.github/workflows/CreateReleaseDraft.yml b/.github/workflows/CreateReleaseDraft.yml index b4fb50b1c..d564f2c20 100644 --- a/.github/workflows/CreateReleaseDraft.yml +++ b/.github/workflows/CreateReleaseDraft.yml @@ -1,10 +1,11 @@ name: Create_Release_Draft +run-name: Create_Release_Draft (${{ inputs.source_sha }}) on: workflow_dispatch: inputs: allow_existing_release: - description: 'Allow uploading assets to an existing release' + description: 'Allow adding assets to an existing draft at the same source SHA' required: true type: boolean default: false @@ -13,14 +14,19 @@ on: required: true type: string -# Least privilege for GITHUB_TOKEN: checkout reads the repo; release creation and -# all gh calls use the MY_GITHUB_TOKEN PAT explicitly. permissions: - contents: read + contents: write + actions: read + +concurrency: + group: release-draft-${{ inputs.source_sha }} + cancel-in-progress: false jobs: build: runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} steps: - name: Checkout repository @@ -42,38 +48,54 @@ jobs: echo "::error::Checkout SHA $actual_sha does not match source_sha $EXPECTED_SOURCE_SHA" exit 1 fi + echo "LOOP_SOURCE_SHA=$actual_sha" >> "$GITHUB_ENV" - - name: Install tools - run: | - sudo apt-get install gh - sudo apt-get install jq + - name: Verify version policy + run: python3 scripts/ci/check_version_policy.py - name: Read version id: get_version run: | version=$(grep -oP 'set\(LOOP_VERSION \K[0-9]+\.[0-9]+\.[0-9]+' "CMakeLists.txt") prerelease=$(grep -oP 'set\(LOOP_VERSION_PRERELEASE \K[A-Za-z0-9.-]+' "CMakeLists.txt" || true) + echo "loop_prerelease=false" >> "$GITHUB_ENV" if [ -n "$prerelease" ]; then version="${version}-${prerelease}" + echo "loop_prerelease=true" >> "$GITHUB_ENV" fi echo "Version: $version" echo "loop_version=$version" >> $GITHUB_ENV - - name: Stop if release already exists + - name: Check release and tag identity env: LOOP_VERSION: ${{ env.loop_version }} - GITHUB_REPOSITORY: ${{ github.repository }} ALLOW_EXISTING_RELEASE: ${{ inputs.allow_existing_release }} - GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} run: | + set -euo pipefail tag="v${LOOP_VERSION}" - if gh release view "$tag" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then - if [ "${ALLOW_EXISTING_RELEASE}" != "true" ]; then - echo "::error::Release $tag already exists. Refusing to upload assets because this would modify an existing release." - echo "::error::Rerun this workflow with allow_existing_release=true only if updating that release is intentional." + if git show-ref --verify --quiet "refs/tags/$tag"; then + tag_sha=$(git rev-parse "refs/tags/$tag^{commit}") + if [ "$tag_sha" != "$LOOP_SOURCE_SHA" ]; then + echo "::error::Tag $tag points to $tag_sha, not $LOOP_SOURCE_SHA." + exit 1 + fi + fi + release=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" \ + --jq ".[] | select(.tag_name == \"$tag\")") + if [ -n "$release" ]; then + if [ "$(jq -r .draft <<< "$release")" != "true" ]; then + echo "::error::Release $tag is published. Only drafts may be updated." + exit 1 + fi + release_target=$(jq -r .target_commitish <<< "$release") + if [ "$release_target" != "$LOOP_SOURCE_SHA" ]; then + echo "::error::Draft $tag targets $release_target, not $LOOP_SOURCE_SHA." + exit 1 + fi + if [ "$ALLOW_EXISTING_RELEASE" != "true" ]; then + echo "::error::Draft $tag exists. Set allow_existing_release=true to add missing assets." exit 1 fi - echo "Release $tag already exists and allow_existing_release=true, continuing." else echo "Release $tag does not exist, continuing." fi @@ -97,8 +119,7 @@ jobs: fi echo "linux_run_id=$(echo "$latest_run" | jq -r .databaseId)" >> $GITHUB_ENV env: - GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} - EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + EXPECTED_SOURCE_SHA: ${{ env.LOOP_SOURCE_SHA }} - name: Get latest run ID for Windows_MSI id: get_windows_run_id @@ -115,31 +136,28 @@ jobs: fi echo "windows_run_id=$(echo "$latest_run" | jq -r .databaseId)" >> $GITHUB_ENV env: - GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} - EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + EXPECTED_SOURCE_SHA: ${{ env.LOOP_SOURCE_SHA }} - name: Download Linux_AppImage artifact run: | mkdir -p artifacts echo "Downloading artifacts from run ID: ${{ env.linux_run_id }}..." - gh run download ${{ env.linux_run_id }} -D artifacts + gh run download "$linux_run_id" --pattern "Loop-pdf-${loop_version}-x86_64.AppImage*" -D artifacts echo "Artifacts downloaded to artifacts directory." - env: - GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} - name: Download Windows_MSI artifact run: | mkdir -p artifacts echo "Downloading artifacts from run ID: ${{ env.windows_run_id }}..." - gh run download ${{ env.windows_run_id }} -D artifacts + gh run download "$windows_run_id" \ + --name "Loop-pdf-Windows-${loop_version}.zip" \ + --name "mberrys.Loop-pdf_${loop_version}.msi" \ + --name "mberrys.Loop-pdf_${loop_version}.msix" -D artifacts echo "Artifacts downloaded to artifacts directory." - env: - GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} - name: Download and validate package boundary evidence env: - GH_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} - EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + EXPECTED_SOURCE_SHA: ${{ env.LOOP_SOURCE_SHA }} run: | set -euo pipefail mkdir -p package-evidence/linux package-evidence/windows @@ -161,12 +179,12 @@ jobs: retention-days: 30 compression-level: 0 - - name: Exclude CI evidence from release assets + - name: Stage final-artifact SBOMs and notices run: | - for evidence_dir in artifacts/loop-package-boundary-linux-evidence artifacts/loop-package-boundary-windows-evidence; do - if [ -e "$evidence_dir" ]; then - rm -rf "$evidence_dir" - fi + set -euo pipefail + for platform in linux windows; do + cp "package-evidence/$platform/components.spdx.json" "artifacts/Loop-${loop_version}-${platform}.spdx.json" + cp "package-evidence/$platform/THIRD_PARTY_NOTICES.txt" "artifacts/Loop-${loop_version}-${platform}-THIRD_PARTY_NOTICES.txt" done - name: Rezip zip file @@ -183,30 +201,32 @@ jobs: - name: Correct file paths run: | set -euo pipefail - # Flatten artifact subdirectories. Fail loudly on a name collision: - # silently overwriting means a release asset is dropped, and - # fail_on_unmatched_files cannot detect that because the glob still matches. + # Artifact directories can share the payload filename. + mkdir -p release-assets while IFS= read -r -d '' src; do - dest="artifacts/$(basename "$src")" + dest="release-assets/$(basename "$src")" if [ -e "$dest" ]; then echo "::error::Artifact file name collision while flattening: $src -> $dest" exit 1 fi mv "$src" "$dest" - done < <(find artifacts -mindepth 2 -type f -print0) + done < <(find artifacts -type f -print0) find artifacts -type d -empty -delete - - name: List all files recursively + - name: Verify release assets against inspected packages run: | - echo "Listing all files in artifacts:" - find artifacts -type f -print + python3 scripts/ci/verify_release_assets.py \ + --artifacts release-assets \ + --pair package-evidence/pair.json \ + --version "$loop_version" \ + --source-sha "$LOOP_SOURCE_SHA" - name: Generate SHA-256 checksums run: | # docs/PACKAGING_LICENSING.md: "Sign artifacts and publish checksums." # Checksums are published here; Authenticode signing of the MSI is gated on # vars.SIGN_MSI in WindowsInstall.yml and requires the code-signing cert. - cd artifacts + cd release-assets # Exclude the output file: the redirection creates it before find runs, so an # unfiltered glob hashes an empty SHA256SUMS.txt into its own listing. find . -maxdepth 1 -type f ! -name SHA256SUMS.txt -printf '%P\0' \ @@ -219,12 +239,13 @@ jobs: uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: tag_name: 'v${{ env.loop_version }}' - name: "Release of Loop-pdf ${{ env.loop_version }} (Draft)" + name: "Loop ${{ env.loop_version }}" + target_commitish: ${{ env.LOOP_SOURCE_SHA }} draft: true - prerelease: false + prerelease: ${{ env.loop_prerelease == 'true' }} generate_release_notes: true - token: ${{ secrets.MY_GITHUB_TOKEN }} + token: ${{ github.token }} fail_on_unmatched_files: true overwrite_files: false files: | - artifacts/* + release-assets/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a83e20871..eee059f97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Test release assets against paired package evidence + run: python -m unittest scripts.ci.test_verify_release_assets -v - name: Test package lifecycle scripts with fake packages run: python -m unittest scripts.ci.test_run_qt_relink_test -v - name: Test Linux AppImage Qt relink script with fake AppImage diff --git a/AGENTS.md b/AGENTS.md index a8c98ef44..a608a87e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Loop agent policy adapter -Repository: `studio-berry/loop`; version: `0.2.0-alpha`; language: `C++20`; minimum Qt: `6.11.1`. +Repository: `studio-berry/loop`; version: `0.2.1-alpha`; language: `C++20`; minimum Qt: `6.11.1`. ## Branches and safety diff --git a/CMakeLists.txt b/CMakeLists.txt index 3b19312d6..faa3068fe 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,7 +22,7 @@ cmake_minimum_required(VERSION 3.16) -set(LOOP_VERSION 0.2.0) +set(LOOP_VERSION 0.2.1) set(LOOP_VERSION_PRERELEASE alpha) set(LOOP_PRODUCT_NAME "Loop") set(LOOP_ORGANIZATION_NAME "Loop") diff --git a/changes/fix-release-0.2.1-alpha.md b/changes/fix-release-0.2.1-alpha.md new file mode 100644 index 000000000..3a061ceaf --- /dev/null +++ b/changes/fix-release-0.2.1-alpha.md @@ -0,0 +1,4 @@ +Category: internal +Audience: contributors +Breaking-Change: no +Summary: Prepare 0.2.1-alpha and repair draft-release authentication, prerelease and source identity, package digest verification, and SBOM/notice attachment. diff --git a/docs/CHANGELOG_FORMAT.md b/docs/CHANGELOG_FORMAT.md new file mode 100644 index 000000000..9eb343087 --- /dev/null +++ b/docs/CHANGELOG_FORMAT.md @@ -0,0 +1,89 @@ +# Loop release changelog format + +The canonical shape for every Loop changelog read outside the diff: promotion PR bodies +(`dev` → `unstable` → `stable`), GitHub release notes and release drafts, and milestone or +version closeouts. It does not replace the per-PR fragment +`changes/.md` (Category, Audience, Breaking-Change, Summary), which +every PR still adds exactly once. + +## Skeleton + +```markdown +# [] + + + +Scope: `` (``) → `` — non-merge commits. +Canonical milestone text: `docs/github-milestones/.md`. + +## Added + +## Changed + +## Fixed + +## Security + +## Internal + +## Closes + +Closes # + +## Verification + +## Breaking changes +``` + +## Rules + +1. **Title** is `[] `, copied verbatim from + `docs/github-milestones/.md`. Work outside a milestone names its train instead of + inventing a title. +2. **The scope line is measured, not estimated.** Take the count from + `git log --oneline --no-merges .. | wc -l` and the base from `git describe --tags` + or the release tag, and name the range by tag **and** SHA. +3. **Sections use the fragment vocabulary in this order:** Added, Changed, Fixed, Security, + Internal. Drop what is empty; never introduce a section the vocabulary does not have. +4. **Every item names its issue and its mechanism** — `(#)` plus what the code now does, or + the file or API that carries it. Adjectives do not replace behaviour, and a catch-all + "various fixes" is not an item. + - Good: `#520 — searchDocumentText() allocates an operation-scoped PDFProcessingBudget, so an idle session's elapsed timer no longer throws through Quick search.` + - Bad: `Improved search reliability.` +5. **Group by outcome, not by commit.** A twelve-commit hardening train is one item naming the + class of input and the invariant it now enforces. +6. **`Closes` is a claim about acceptance criteria, not about commits.** An issue is closed only + when the shipped diff meets the criteria written in its body. Partial work goes to `Refs` + with the outstanding criterion named. A closing keyword is never added to make a milestone + read as finished. +7. **One keyword per line.** `Closes #10` and `Closes #11` are two lines; a comma list is not + this format. +8. **Record who closes them.** Closing keywords act only when the PR merges into the default + branch (`stable`). Work that landed on `dev` or `unstable` leaves its issue open by design, + and the block says so rather than leaving the reader to work it out. +9. **Verification cites observable state** — workflow, SHA or tag, and result. "Verified + locally" without the command and its output is not verification. +10. **Breaking changes are stated, never implied.** Either `None.` with the reason it is none + (additive API, no schema or persistence-format bump), or the migration and its ordering + constraint. +11. **No claim a `git` or `gh` command cannot back.** Counts, PR numbers, SHAs, tags, check + conclusions and issue states are copied from command output. When a fact could not be + established, the changelog says that instead of asserting it. + +## Placement + +- **Promotion PRs** carry the whole format in the PR body. The PR body is the changelog. +- **Releases** reuse that body for the release notes or the generated release draft; a second + copy is not maintained by hand. +- **Milestones** may copy the merged promotion body into their evidence directory. The PR body + stays authoritative until the copy is made. + +## Worked example + +`[0.2.1] Operator Completion, Product Surface & Trust Leftovers`, the promotion +`unstable` → `stable` for milestone 17: a scope line over `0.2.0.1-alpha` (`86ee1b5b`) → +`d2e9ce9f`; items under Added / Changed / Fixed / Security / Internal, each naming its issue and +mechanism; sixteen `Closes` lines with `#236` marked as already shipped in `0.2.0.1-alpha`; +`Refs #241` with the outstanding criterion named (the renderer-differential lane and documented +corpus licensing); a verification line naming four green workflows at a SHA; and +`Breaking changes: None.` with the reason. diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index e1aa4a7b8..02047975e 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -10,15 +10,15 @@ The declarations below are intentionally machine-readable by that check: - Scheme: SemVer 2.0 - Canonical version: `LOOP_VERSION` in root `CMakeLists.txt` - Format: MAJOR.MINOR.PATCH -- Current version: 0.2.0 +- Current version: 0.2.1 - Pre-release: alpha - Git tags: `vMAJOR.MINOR.PATCH` with optional pre-release / build metadata - Windows Appx version: MAJOR.MINOR.PATCH.0 (`LOOP_WINDOWS_VERSION`) - Release workflow: `.github/workflows/CreateReleaseDraft.yml` -The current line is **0.2.0-alpha**. Display strings, PdfTool's envelope +The current line is **0.2.1-alpha**. Display strings, PdfTool's envelope `version`, and git tags use that identifier. CMake's -`project(VERSION)` stays `0.2.0` because it only accepts numeric components; +`project(VERSION)` stays `0.2.1` because it only accepts numeric components; the `-alpha` label lives in `LOOP_VERSION_PRERELEASE`. ## Milestone remap @@ -65,7 +65,7 @@ Given `MAJOR.MINOR.PATCH`: | Backward-compatible bug fix | PATCH | Set `LOOP_VERSION_PRERELEASE` in root `CMakeLists.txt` for `-alpha`, -`-rc.1`, and similar labels. Clear it for a final `0.2.0`. Build metadata +`-rc.1`, and similar labels. Clear it for a final `0.2.1`. Build metadata (`+githash`) belongs on tags only. ## Source of truth and packaging diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index f947a30cd..ee0fce619 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -470,7 +470,7 @@ ], "version_policy": { "cmake_format": "MAJOR.MINOR.PATCH", - "current": "0.2.0", + "current": "0.2.1", "prerelease": "alpha", "scheme": "semver", "tag_prefix": "v" diff --git a/docs/version-policy.json b/docs/version-policy.json index d5a8b20cd..3144d37ae 100644 --- a/docs/version-policy.json +++ b/docs/version-policy.json @@ -3,7 +3,7 @@ "spec": "https://semver.org/spec/v2.0.0.html", "cmake_variable": "LOOP_VERSION", "cmake_format": "MAJOR.MINOR.PATCH", - "current": "0.2.0", + "current": "0.2.1", "prerelease": "alpha", "tag_prefix": "v", "windows_variable": "LOOP_WINDOWS_VERSION", diff --git a/scripts/ci/test_verify_release_assets.py b/scripts/ci/test_verify_release_assets.py new file mode 100644 index 000000000..15da3d3ec --- /dev/null +++ b/scripts/ci/test_verify_release_assets.py @@ -0,0 +1,96 @@ +"""Release assembly rejects missing, unrelated, and substituted package assets.""" + +from __future__ import annotations + +import hashlib +import json +import tempfile +import unittest +from pathlib import Path + +from scripts.ci.verify_release_assets import verify + + +class ReleaseAssetTests(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.artifacts = self.root / "artifacts" + self.artifacts.mkdir() + self.version = "0.2.1-alpha" + self.source_sha = "a" * 40 + self.linux = "Loop-pdf-0.2.1-alpha-x86_64.AppImage" + self.windows = "mberrys.Loop-pdf_0.2.1-alpha.msi" + names = [ + self.linux, self.windows, f"{self.linux}.zsync", + "mberrys.Loop-pdf_0.2.1-alpha.msix", "Loop-pdf-Windows-0.2.1-alpha.zip", + "Loop-0.2.1-alpha-linux.spdx.json", "Loop-0.2.1-alpha-windows.spdx.json", + "Loop-0.2.1-alpha-linux-THIRD_PARTY_NOTICES.txt", + "Loop-0.2.1-alpha-windows-THIRD_PARTY_NOTICES.txt", + ] + for name in names: + (self.artifacts / name).write_bytes(b"package") + self.pair = { + "schema_version": 1, + "kind": "loop-package-boundary-pair", + "status": "passed", + "source_sha": self.source_sha, + "packages": { + platform: {"name": name, "size": 7, "sha256": hashlib.sha256(b"package").hexdigest()} + for platform, name in (("linux", self.linux), ("windows", self.windows)) + }, + } + self.pair_path = self.root / "pair.json" + + def verify(self): + self.pair_path.write_text(json.dumps(self.pair), encoding="utf-8") + verify(self.artifacts, self.pair_path, self.version, self.source_sha) + + def test_complete_unsigned_and_signed_assets(self): + self.verify() + (self.artifacts / f"{self.linux}.sig").write_bytes(b"signature") + self.verify() + + def test_rejects_package_substitution_with_same_size(self): + for name in (self.linux, self.windows): + with self.subTest(name=name): + (self.artifacts / name).write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "digest"): + self.verify() + (self.artifacts / name).write_bytes(b"package") + + def test_rejects_wrong_source_and_failed_evidence(self): + self.pair["source_sha"] = "b" * 40 + with self.assertRaisesRegex(ValueError, "passed pair evidence"): + self.verify() + self.pair["source_sha"] = self.source_sha + self.pair["status"] = "failed" + with self.assertRaisesRegex(ValueError, "passed pair evidence"): + self.verify() + + def test_rejects_missing_installer_or_licensing_asset(self): + for name in (self.windows, "Loop-0.2.1-alpha-linux.spdx.json"): + with self.subTest(name=name): + (self.artifacts / name).unlink() + with self.assertRaisesRegex(ValueError, "missing="): + self.verify() + (self.artifacts / name).write_bytes(b"package") + + def test_rejects_ci_evidence_among_release_assets(self): + (self.artifacts / "crash.dmp").write_bytes(b"diagnostic") + with self.assertRaisesRegex(ValueError, "unexpected=.*crash.dmp"): + self.verify() + + def test_rejects_empty_asset_and_package_from_another_version(self): + (self.artifacts / self.windows).write_bytes(b"") + with self.assertRaisesRegex(ValueError, "nonempty regular file"): + self.verify() + (self.artifacts / self.windows).write_bytes(b"package") + self.pair["packages"]["windows"]["name"] = "mberrys.Loop-pdf_0.2.0-alpha.msi" + with self.assertRaisesRegex(ValueError, "identity"): + self.verify() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index 42ca98b76..fa6829359 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -224,7 +224,7 @@ def test_release_draft_pairs_evidence_and_keeps_it_out_of_assets(self): self.assertIn('--arg title "Linux_AppImage (${EXPECTED_SOURCE_SHA})"', workflow) self.assertIn('--arg title "Windows_MSI (${EXPECTED_SOURCE_SHA})"', workflow) self.assertNotIn('--commit "$EXPECTED_SOURCE_SHA"', workflow) - self.assertIn("Exclude CI evidence from release assets", workflow) + self.assertIn("verify_release_assets.py", workflow) self.assertIn("source_sha", workflow) def test_windows_relink_runs_before_msi_uninstall(self): diff --git a/scripts/ci/verify_release_assets.py b/scripts/ci/verify_release_assets.py new file mode 100644 index 000000000..06e70148f --- /dev/null +++ b/scripts/ci/verify_release_assets.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Check the complete release asset set against paired package inspection evidence.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + + +VERSION = re.compile(r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[A-Za-z0-9.-]+)?") + + +def verify(artifacts: Path, pair_path: Path, version: str, source_sha: str) -> None: + if not VERSION.fullmatch(version) or not re.fullmatch(r"[0-9a-fA-F]{40}", source_sha): + raise ValueError("expected a release version and full source SHA") + pair = json.loads(pair_path.read_text(encoding="utf-8")) + if ( + pair.get("schema_version") != 1 + or pair.get("kind") != "loop-package-boundary-pair" + or pair.get("status") != "passed" + or pair.get("source_sha") != source_sha.lower() + ): + raise ValueError("release assets require passed pair evidence for the source SHA") + + packages = { + "linux": f"Loop-pdf-{version}-x86_64.AppImage", + "windows": f"mberrys.Loop-pdf_{version}.msi", + } + required = { + *packages.values(), + f"{packages['linux']}.zsync", + f"mberrys.Loop-pdf_{version}.msix", + f"Loop-pdf-Windows-{version}.zip", + *(f"Loop-{version}-{platform}.spdx.json" for platform in packages), + *(f"Loop-{version}-{platform}-THIRD_PARTY_NOTICES.txt" for platform in packages), + } + allowed = required | {f"{packages['linux']}.sig"} + paths = {path.name: path for path in artifacts.iterdir()} + missing = required - paths.keys() + unexpected = paths.keys() - allowed + if missing or unexpected: + raise ValueError(f"release asset mismatch: missing={sorted(missing)}, unexpected={sorted(unexpected)}") + for path in paths.values(): + if path.is_symlink() or not path.is_file() or path.stat().st_size == 0: + raise ValueError(f"release asset must be a nonempty regular file: {path.name}") + + for platform, name in packages.items(): + inspected = pair.get("packages", {}).get(platform, {}) + path = paths[name] + if inspected.get("name") != name or inspected.get("size") != path.stat().st_size: + raise ValueError(f"inspected package identity does not match release asset: {name}") + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + if digest.hexdigest() != inspected.get("sha256"): + raise ValueError(f"release asset digest does not match inspected package: {name}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts", type=Path, required=True) + parser.add_argument("--pair", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--source-sha", required=True) + args = parser.parse_args() + try: + verify(args.artifacts, args.pair, args.version, args.source_sha) + except (OSError, ValueError) as exc: + print(f"Release assets FAILED: {exc}", file=sys.stderr) + return 1 + print(f"Release assets verified: version={args.version} source_sha={args.source_sha}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())