diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..892bd6a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,154 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + shared: + name: Shared checks (${{ matrix.name }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - name: macOS + runner: macos-15 + - name: Ubuntu + runner: ubuntu-24.04 + - name: Windows + runner: windows-2025 + + steps: + - name: Check out source and test corpus + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + lfs: true + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Install locked Python environment tool + run: python -m pip install uv==0.11.7 + + - name: Test release artifact contract + run: python -m unittest discover -s scripts/tests -v + + - name: Check release tooling formatting + run: >- + uv run --project backend --frozen --only-group ci ruff format --check + scripts/release_artifact.py scripts/tests/test_release_artifact.py + + - name: Lint release tooling + run: >- + uv run --project backend --frozen --only-group ci ruff check + scripts/release_artifact.py scripts/tests/test_release_artifact.py + + - name: Check portable Python formatting + working-directory: backend + run: uv run --frozen --only-group ci ruff format --check . + + - name: Lint portable Python + working-directory: backend + run: uv run --frozen --only-group ci ruff check . + + - name: Test portable Python services + working-directory: backend + run: >- + uv run --frozen --only-group ci python -m pytest + tests/test_loras.py + tests/test_worker.py + tests/test_sidecar.py + tests/test_controller.py + tests/test_frozen.py + + # Most DeckEngine behavior is model-independent. The excluded tests cross + # the Magenta/MLX import boundary and remain in the local full suite and + # the signed macOS release runtime check. + - name: Test portable Python deck behavior + working-directory: backend + run: >- + uv run --frozen --only-group ci python -m pytest tests/test_engine.py + -k "not constructor_uses_reference_sampling_defaults + and not embed_sample + and not sample_key_never_hits_the_text_embedder + and not sample_cache + and not failed_embed_does_not_evict" + + # SA3 uses a copied Python interpreter and fake CLI; no runtime, weights, + # accelerator, shell, or network is involved. The selected model-manager + # nodes exercise only SA3 readiness. Its Magenta discovery/download nodes + # remain behind the backend-specific runtime gate. + - name: Test model-free Python runtime contracts + working-directory: backend + run: >- + uv run --frozen --only-group ci python -m pytest + tests/test_sa3.py + tests/test_models.py::test_readiness_classifies_a_checkout + tests/test_models.py::test_readiness_missing_when_no_checkout + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Lint frontend + working-directory: frontend + run: npm run lint + + - name: Type-check frontend + working-directory: frontend + run: npx tsc -b + + - name: Test frontend + working-directory: frontend + run: npm test + + - name: Build frontend assets for native shell + working-directory: frontend + run: npm run build + + - name: Install Ubuntu native build dependencies + if: runner.os == 'Linux' + run: >- + sudo apt-get update && sudo apt-get install --yes + build-essential + libasound2-dev + libayatana-appindicator3-dev + libgtk-3-dev + libssl-dev + libudev-dev + libwebkit2gtk-4.1-dev + libxdo-dev + librsvg2-dev + + - name: Set up Rust + run: rustup toolchain install stable --profile minimal --no-self-update + + - name: Test Rust workspace + run: cargo test --locked --workspace --manifest-path src-tauri/Cargo.toml + + - name: Lint Rust workspace + run: >- + cargo clippy --locked --workspace --all-targets + --manifest-path src-tauri/Cargo.toml -- -D warnings diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 01d8608..7cdb514 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -1,6 +1,6 @@ -name: macOS release +name: Release -run-name: macOS release from ${{ github.ref_name }} by @${{ github.actor }} +run-name: Release ${{ github.ref_name }} by @${{ github.actor }} # A protected release tag starts validation. Signing credentials remain behind # the macos-release Environment's separate human approval gate. @@ -24,6 +24,8 @@ jobs: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + revision: ${{ steps.release_identity.outputs.revision }} steps: - name: Check out the tagged commit @@ -33,6 +35,7 @@ jobs: persist-credentials: false - name: Verify release tag and ancestry + id: release_identity shell: bash run: | set -euo pipefail @@ -51,8 +54,10 @@ jobs: } echo "Validated $GITHUB_REF_NAME at $TAG_COMMIT" + echo "revision=$TAG_COMMIT" >> "$GITHUB_OUTPUT" - release: + produce_macos: + name: Produce macOS arm64 artifact needs: validate if: >- needs.validate.result == 'success' && @@ -87,6 +92,11 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + - name: Check runner architecture run: | set -euo pipefail @@ -198,11 +208,32 @@ jobs: set -euo pipefail just tauri-release - - name: Upload verified DMG + - name: Package verified release artifact + env: + LSDJ_RELEASE_REVISION: ${{ needs.validate.outputs.revision }} + run: | + set -euo pipefail + shopt -s nullglob + DMG_FILES=(src-tauri/target/release/bundle/dmg/*.dmg) + [[ "${#DMG_FILES[@]}" -eq 1 ]] || { + echo "Expected exactly one verified DMG, found ${#DMG_FILES[@]}" >&2 + exit 1 + } + + python scripts/release_artifact.py create \ + --producer macos-arm64 \ + --release-tag "$GITHUB_REF_NAME" \ + --revision "$LSDJ_RELEASE_REVISION" \ + --asset "${DMG_FILES[0]}" \ + --output-dir release-artifacts/macos-arm64 + + # The publisher receives installers only through immutable per-run + # Actions artifacts, together with the tag/revision metadata and digest. + - name: Upload verified producer bundle uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: LSDJ-macOS-arm64 - path: src-tauri/target/release/bundle/dmg/*.dmg + name: release-macos-arm64 + path: release-artifacts/macos-arm64 if-no-files-found: error retention-days: 14 @@ -218,9 +249,13 @@ jobs: "${LSDJ_API_KEY_PATH:-}" publish: - needs: release + name: Verify and publish complete release + needs: + - validate + - produce_macos if: >- - needs.release.result == 'success' && + needs.validate.result == 'success' && + needs.produce_macos.result == 'success' && github.repository == 'protocol-works/lsdj' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest @@ -229,26 +264,120 @@ jobs: contents: write steps: - - name: Download verified DMG + - name: Check out the approved release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Download macOS producer bundle uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: LSDJ-macOS-arm64 - path: dist + name: release-macos-arm64 + path: release-input/macos-arm64 - - name: Publish GitHub Release + - name: Verify complete required producer set + env: + LSDJ_RELEASE_REVISION: ${{ needs.validate.outputs.revision }} + shell: bash + run: | + set -euo pipefail + python scripts/release_artifact.py verify \ + --input-root release-input \ + --required-producer macos-arm64 \ + --release-tag "$GITHUB_REF_NAME" \ + --revision "$LSDJ_RELEASE_REVISION" \ + --output-dir verified-release + + - name: Publish verified GitHub Release env: GH_TOKEN: ${{ github.token }} + LSDJ_RELEASE_REVISION: ${{ needs.validate.outputs.revision }} shell: bash run: | set -euo pipefail - mapfile -t DMG_FILES < <(find dist -maxdepth 1 -type f -name '*.dmg' -print) - [[ "${#DMG_FILES[@]}" -eq 1 ]] || { - echo "Expected exactly one verified DMG, found ${#DMG_FILES[@]}" >&2 + if gh release view "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "A GitHub Release already exists for $GITHUB_REF_NAME" >&2 + exit 1 + fi + + mapfile -d '' -t RELEASE_FILES < <( + find verified-release -maxdepth 1 -type f -print0 | sort -z + ) + [[ "${#RELEASE_FILES[@]}" -gt 0 ]] || { + echo "The verified release contains no files" >&2 + exit 1 + } + + DRAFT_RELEASE_ID="" + PUBLISHED=0 + cleanup_draft() { + result=$? + if [[ -n "$DRAFT_RELEASE_ID" && "$PUBLISHED" -ne 1 ]]; then + CLEANUP_JSON="$RUNNER_TEMP/lsdj-cleanup-release.json" + if gh api \ + "repos/$GITHUB_REPOSITORY/releases/$DRAFT_RELEASE_ID" \ + > "$CLEANUP_JSON" 2>/dev/null && \ + python scripts/release_artifact.py verify-draft-identity \ + --release-json "$CLEANUP_JSON" \ + --release-tag "$GITHUB_REF_NAME" \ + --revision "$LSDJ_RELEASE_REVISION" \ + --expected-release-id "$DRAFT_RELEASE_ID" >/dev/null; then + echo "Publication failed; removing this run's unpublished draft" >&2 + gh api --method DELETE \ + "repos/$GITHUB_REPOSITORY/releases/$DRAFT_RELEASE_ID" || \ + echo "Could not remove this run's unpublished draft" >&2 + else + echo "Could not prove this run still owns the failed draft; leaving it in place" >&2 + fi + fi + exit "$result" + } + trap cleanup_draft EXIT + + git fetch --no-tags origin \ + "+$GITHUB_REF:refs/remotes/origin/lsdj-release-tag" + REMOTE_TAG_COMMIT="$(git rev-list -n 1 refs/remotes/origin/lsdj-release-tag)" + [[ "$REMOTE_TAG_COMMIT" == "$LSDJ_RELEASE_REVISION" ]] || { + echo "Release tag no longer resolves to the approved source revision" >&2 exit 1 } - gh release create "$GITHUB_REF_NAME" "${DMG_FILES[0]}" \ - --repo "$GITHUB_REPOSITORY" \ - --verify-tag \ - --generate-notes \ - --title "LSDJ $GITHUB_REF_NAME" + CREATE_RESPONSE="$RUNNER_TEMP/lsdj-created-release.json" + SOURCE_MARKER="Source revision: $LSDJ_RELEASE_REVISION" + gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + --field "tag_name=$GITHUB_REF_NAME" \ + --field "target_commitish=$LSDJ_RELEASE_REVISION" \ + --field "name=LSDJ $GITHUB_REF_NAME" \ + --field "body=$SOURCE_MARKER" \ + --field draft=true \ + --field generate_release_notes=true \ + > "$CREATE_RESPONSE" + DRAFT_RELEASE_ID="$(python scripts/release_artifact.py \ + verify-draft-identity \ + --release-json "$CREATE_RESPONSE" \ + --release-tag "$GITHUB_REF_NAME" \ + --revision "$LSDJ_RELEASE_REVISION")" + + gh release upload "$GITHUB_REF_NAME" "${RELEASE_FILES[@]}" \ + --repo "$GITHUB_REPOSITORY" + + RELEASE_JSON="$RUNNER_TEMP/lsdj-draft-release.json" + gh api "repos/$GITHUB_REPOSITORY/releases/$DRAFT_RELEASE_ID" \ + > "$RELEASE_JSON" + python scripts/release_artifact.py verify-github-release \ + --release-json "$RELEASE_JSON" \ + --verified-dir verified-release \ + --release-tag "$GITHUB_REF_NAME" \ + --revision "$LSDJ_RELEASE_REVISION" \ + --expected-release-id "$DRAFT_RELEASE_ID" + + gh api --method PATCH \ + "repos/$GITHUB_REPOSITORY/releases/$DRAFT_RELEASE_ID" \ + --field draft=false >/dev/null + PUBLISHED=1 diff --git a/backend/lsdj/controller.py b/backend/lsdj/controller.py index 3a9ec9f..2e3893a 100644 --- a/backend/lsdj/controller.py +++ b/backend/lsdj/controller.py @@ -39,7 +39,44 @@ MODEL_RAM_ESTIMATE_GB = {"mrt2_small": 2.0, "mrt2_base": 6.0} +def _windows_total_ram_bytes(kernel32=None) -> int: + """Read physical RAM through the Windows kernel API. + + ``os.sysconf`` is Unix-only. Keep this standard-library-only so the model + status endpoint remains available before any optional model runtime loads. + ``kernel32`` is injectable for a platform-independent contract test. + """ + import ctypes + + class MemoryStatusEx(ctypes.Structure): + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + + status = MemoryStatusEx() + status.dwLength = ctypes.sizeof(status) + if kernel32 is None: + api = ctypes.windll.kernel32.GlobalMemoryStatusEx + api.argtypes = [ctypes.POINTER(MemoryStatusEx)] + api.restype = ctypes.c_int + else: + api = kernel32.GlobalMemoryStatusEx + if not api(ctypes.byref(status)): + raise OSError(ctypes.get_last_error(), "GlobalMemoryStatusEx failed") + return status.ullTotalPhys + + def _total_ram_gb() -> float: + if os.name == "nt": + return _windows_total_ram_bytes() / 1024**3 return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1024**3 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0f237ae..b3266e5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,6 +23,18 @@ dev = [ # scripts/freeze-sidecar.sh). A build tool, not a runtime dep. "pyinstaller>=6.21", ] +# Model runtimes are platform-specific, but most controller, transport, and +# validation tests are not. CI installs this locked group on every OS so those +# shared contracts stay testable without pretending that MLX is portable. +ci = [ + "fastapi>=0.136.3", + "httpx>=0.27", + "numpy>=2.2", + "pytest>=8", + "python-multipart>=0.0.32", + "ruff>=0.8", + "uvicorn>=0.49.0", +] [build-system] requires = ["hatchling"] diff --git a/backend/tests/test_controller.py b/backend/tests/test_controller.py index 484d54f..1720317 100644 --- a/backend/tests/test_controller.py +++ b/backend/tests/test_controller.py @@ -781,3 +781,13 @@ def test_models_endpoint_returns_list_and_ram(client, monkeypatch): assert body["sample_rate"] == 48000 assert body["total_ram_gb"] > 0 assert "mrt2_small" in body["model_ram_estimate_gb"] + + +def test_windows_ram_detection_uses_global_memory_status() -> None: + class FakeKernel32: + @staticmethod + def GlobalMemoryStatusEx(status_pointer): + status_pointer._obj.ullTotalPhys = 16 * 1024**3 + return 1 + + assert controller._windows_total_ram_bytes(FakeKernel32()) == 16 * 1024**3 diff --git a/backend/tests/test_sa3.py b/backend/tests/test_sa3.py index 8ddfa2c..fe1a2e9 100644 --- a/backend/tests/test_sa3.py +++ b/backend/tests/test_sa3.py @@ -1,12 +1,15 @@ """sa3 generation tests: checkout resolution and the subprocess contract. -A stub `python` executable stands in for the sa3_mlx venv so the real -spawn path — argument passing, --out handling, failure and timeout -mapping — is exercised without MLX or weights. +A copied or linked Python interpreter runs a fake sa3_mlx CLI so the real spawn +path — argument passing, --out handling, failure, and timeout mapping — is +exercised without MLX or weights. """ import asyncio +import os import pathlib +import shutil +import sys import pytest @@ -15,41 +18,58 @@ FAKE_WAV = b"RIFFfakewavdata" # Writes the fake WAV to whatever follows --out and records one argv element per -# line beside itself (.venv/bin/argv.txt) so tests can assert the exact CLI -# contract. If init audio is present, copy it before the temporary dir disappears. -SUCCESS_STUB = """#!/bin/sh -out="" -prev="" -: > "$(dirname "$0")/argv.txt" -for arg in "$@"; do - if [ "$prev" = "--out" ]; then out="$arg"; fi - if [ "$prev" = "--init-audio" ]; then cp "$arg" "$(dirname "$0")/init.wav"; fi - printf '%s\\n' "$arg" >> "$(dirname "$0")/argv.txt" - prev="$arg" -done -printf 'RIFFfakewavdata' > "$out" +# line beside the copied venv interpreter so tests can assert the exact CLI +# contract. This is Python rather than a shell stub so the subprocess contract +# runs unchanged on macOS, Linux, and Windows without a model runtime. +SUCCESS_STUB = """import pathlib +import shutil +import sys + +args = [sys.argv[0], *sys.argv[1:]] +runtime_dir = pathlib.Path(sys.executable).parent +(runtime_dir / "argv.txt").write_text("\\n".join(args) + "\\n") +out = pathlib.Path(sys.argv[sys.argv.index("--out") + 1]) +if "--init-audio" in sys.argv: + init_audio = pathlib.Path(sys.argv[sys.argv.index("--init-audio") + 1]) + shutil.copyfile(init_audio, runtime_dir / "init.wav") +out.write_bytes(b"RIFFfakewavdata") """ -FAILURE_STUB = """#!/bin/sh -echo "error: no DiT weights found" -exit 3 +FAILURE_STUB = """import sys +print("error: no DiT weights found") +sys.exit(3) """ # Exits cleanly without writing the WAV. -SILENT_STUB = """#!/bin/sh -exit 0 +SILENT_STUB = """pass +""" + +TIMEOUT_STUB = """import time +time.sleep(30) """ def make_checkout(root: pathlib.Path, stub_body: str) -> pathlib.Path: - """Lay out /optimized/mlx with an executable python stub.""" + """Lay out /optimized/mlx with a portable fake CLI runtime.""" mlx_dir = root / "optimized" / "mlx" (mlx_dir / ".venv" / "bin").mkdir(parents=True) (mlx_dir / "scripts").mkdir() - (mlx_dir / "scripts" / "sa3_mlx.py").write_text("# stub CLI\n") + (mlx_dir / "scripts" / "sa3_mlx.py").write_text(stub_body) + (mlx_dir / ".venv" / "pyvenv.cfg").write_text( + f"home = {sys.base_prefix}\n" + "include-system-site-packages = false\n" + f"version = {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}\n" + ) python = mlx_dir / ".venv" / "bin" / "python" - python.write_text(stub_body) - python.chmod(0o755) + if os.name == "nt": + # Creating symlinks normally requires elevated Windows privileges. + # Keep the extensionless contract probe and the executable name that + # CreateProcess appends when an argv program has no extension. + shutil.copyfile(sys.executable, python) + shutil.copyfile(sys.executable, python.with_suffix(".exe")) + else: + # Preserve relocatable interpreter/library relationships on Unix. + python.symlink_to(sys.executable) return mlx_dir @@ -228,7 +248,7 @@ def test_clean_exit_without_wav_is_a_failure(self, checkout): def test_timeout_kills_and_raises(self, checkout, monkeypatch): # The deadline is base + seconds (timeout_for), so a short clip # keeps the test fast while exercising the real kill path. - checkout("#!/bin/sh\nsleep 30\n") + checkout(TIMEOUT_STUB) monkeypatch.setattr(sa3, "TIMEOUT_SECONDS", 0.2) with pytest.raises(sa3.GenerationFailed, match="timed out"): asyncio.run(sa3.generate("anything", 0.5, "sfx")) diff --git a/backend/uv.lock b/backend/uv.lock index a0d4a40..8daa307 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -825,6 +825,15 @@ dependencies = [ ] [package.dev-dependencies] +ci = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "numpy" }, + { name = "pytest" }, + { name = "python-multipart" }, + { name = "ruff" }, + { name = "uvicorn" }, +] dev = [ { name = "httpx" }, { name = "pyinstaller" }, @@ -842,6 +851,15 @@ requires-dist = [ ] [package.metadata.requires-dev] +ci = [ + { name = "fastapi", specifier = ">=0.136.3" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "numpy", specifier = ">=2.2" }, + { name = "pytest", specifier = ">=8" }, + { name = "python-multipart", specifier = ">=0.0.32" }, + { name = "ruff", specifier = ">=0.8" }, + { name = "uvicorn", specifier = ">=0.49.0" }, +] dev = [ { name = "httpx", specifier = ">=0.27" }, { name = "pyinstaller", specifier = ">=6.21" }, diff --git a/docs/cross-platform-ci-and-release.md b/docs/cross-platform-ci-and-release.md new file mode 100644 index 0000000..48b8173 --- /dev/null +++ b/docs/cross-platform-ci-and-release.md @@ -0,0 +1,106 @@ +# Cross-platform CI and release contract + +Issue #107 introduces two related but deliberately separate gates: + +1. shared software contracts that can run unattended on GitHub-hosted macOS, + Ubuntu, and Windows runners; and +2. qualification that requires real audio, MIDI, GPU, installer, or signing + hardware and credentials. + +Passing CI means a change is portable at the code and build boundary. It does +not by itself claim that a particular device, accelerator, or installer has +been qualified. + +## Automated shared checks + +`.github/workflows/ci.yml` runs the same three-stack checks on `macos-15`, +`ubuntu-24.04`, and `windows-2025`: + +- frontend lint, TypeScript checking, and the complete Vitest suite; +- Rust workspace tests and Clippy, including each OS's compiled platform code; +- Python lint plus controller, worker, transport, validation, model-free deck + behavior, SA3 subprocess, and SA3 readiness tests; and +- release artifact contract tests, including checksum, identity, completeness, + and pre-publication draft verification failures. + +The backend's locked `ci` dependency group intentionally omits Magenta, MLX, +PyTorch, TFLite, and model weights. The SA3 process tests use a copied Python +interpreter and fake CLI, so argument passing, output handling, failure, and +timeout behavior run on all three operating systems. Only tests that actually +import a model runtime stay in the full local `just check` suite and in +backend-specific qualification. This keeps a shared Python regression gate +honest without installing an unsupported accelerator on a runner. + +The Windows matrix uses runner-native `python`, `npm`, `rustup`, and `cargo` +commands. Shell-specific system package installation is restricted to the +Ubuntu step. + +## Hardware-only qualification + +The following evidence must be recorded in the platform issue or its linked +qualification run. It is not replaced by green hosted-runner CI: + +| Surface | Minimum real-system evidence | +| --- | --- | +| Audio output | Enumerate and play through representative mono, stereo, integer, and multichannel devices; verify master/cue routing, underrun behavior, device loss, and recovery. | +| MIDI | Connect supported controllers; verify input, LEDs, hot-plug, shutdown, and reconnect behavior with native drivers. | +| MRT2 | Run two simultaneous decks at both chunk sizes on the supported CPU/GPU backend; capture startup, p50/p95/p99 latency, RAM/VRAM, sustained playback, and teardown. | +| Stable Audio 3 | Generate every supported duration/kind on the supported CPU/GPU backend; verify cancellation, progress, failure cleanup, and output playback. | +| App lifecycle | Install on a clean user account, launch without developer tools, install/remove models, survive paths with spaces/non-ASCII text, and leave no worker processes after normal or forced shutdown. | +| Distribution | Exercise the native installer/uninstaller, OS trust prompts, signing where applicable, checksum validation, offline behavior after install, and a representative antivirus scan. | + +CI tests should use fakes or loopback devices only when they verify a shared +contract. A fake result must not be reported as hardware qualification. + +## Release producer/publisher boundary + +The tag workflow keeps macOS as the only required release artifact initially. +It has three stages: + +1. `validate` accepts only a calendar-version `v*` tag whose commit is contained + in `main`. +2. `produce-macos` waits behind the protected `macos-release` Environment, + freezes the backend, imports ephemeral signing material, builds, signs, + notarizes, staples, and verifies the app and DMG. It then uploads one Actions + artifact containing the DMG, `SHA256SUMS.txt`, and metadata binding the + producer to the tag and exact source revision. +3. `publish` is the only job with `contents: write`. It downloads every required + producer bundle, requires the producer set to match exactly, recomputes all + sizes and SHA-256 digests, and verifies tag/revision/platform metadata before + it creates a GitHub Release. + +The publisher creates an unpublished draft, uploads the complete verified file +set, checks GitHub's returned asset names, sizes, upload state, and SHA-256 +digest, and only then makes the release public. A missing digest fails closed. +The published checksum and release-index files provide the same cryptographic +verification surface to downloaders. Creation records the draft's immutable +numeric release ID plus its tag and exact source revision. Verification, +publication, and failure cleanup remain bound to that ID; cleanup rechecks that +the same release is still a draft with the expected tag and source marker before +deleting it. A tag lookup therefore cannot redirect cleanup to a collaborator's +replacement draft. A failure before publication keeps the release private and +attempts to remove only the draft created by that run. An existing release is +never overwritten. + +Signing and notarization secrets exist only in the macOS producer. The +publisher receives no signing credentials, and producers never receive +`contents: write`. + +## Adding a release platform + +Linux or Windows artifacts become required only in the change that adds their +production installer. That change must, together: + +- add a named producer job with its platform-native build and trust checks; +- add the producer policy to `scripts/release_artifact.py`; +- upload its installer, checksum, and tag/revision metadata as one Actions + artifact; +- add the producer to the publisher's `needs` list and + `--required-producer` arguments; and +- extend the release contract tests and real-system qualification record. + +There must remain exactly one publisher job. It must wait for every required +producer and fail closed if any producer is absent, skipped, duplicated, +unexpected, or inconsistent. Optional best-effort release artifacts are not +published. The verifier treats every configured producer policy as required, +so the workflow's `--required-producer` list cannot silently omit a new policy. diff --git a/scripts/release_artifact.py b/scripts/release_artifact.py new file mode 100644 index 0000000..30db2d4 --- /dev/null +++ b/scripts/release_artifact.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +"""Create and verify fail-closed release producer bundles. + +Release producers never hand a bare installer to the publisher. Each producer +uploads a directory containing its assets, a checksum file, and metadata that +binds those assets to the release tag and source revision. The publisher uses +this module to verify every required producer before it creates a draft GitHub +Release, then verifies the uploaded draft before making it public. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import NoReturn + +SCHEMA_VERSION = 1 +METADATA_NAME = "release-metadata.json" +CHECKSUMS_NAME = "SHA256SUMS.txt" +TAG_PATTERN = re.compile(r"^v[0-9]{4}\.(0[1-9]|1[0-2])\.[1-9][0-9]*$") +REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$") +PRODUCER_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$") +PORTABLE_FILENAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") +WINDOWS_RESERVED_STEMS = { + "aux", + "clock$", + "con", + "nul", + "prn", + *(f"com{number}" for number in range(1, 10)), + *(f"lpt{number}" for number in range(1, 10)), +} + + +class ArtifactError(RuntimeError): + """The producer bundle or draft release violated the release contract.""" + + +@dataclass(frozen=True) +class ProducerPolicy: + platform: str + architecture: str + asset_suffix: str + asset_count: int + + +# macOS is the sole required release producer initially. Adding a platform is +# an explicit policy change: add its producer here and to the publisher's +# --required-producer list in the workflow in the same reviewed change. +PRODUCER_POLICIES = { + "macos-arm64": ProducerPolicy( + platform="macos", + architecture="arm64", + asset_suffix=".dmg", + asset_count=1, + ), +} + + +def fail(message: str) -> NoReturn: + raise ArtifactError(message) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def require_release_identity(release_tag: str, revision: str) -> None: + if not TAG_PATTERN.fullmatch(release_tag): + fail(f"invalid release tag: {release_tag!r}") + if not REVISION_PATTERN.fullmatch(revision): + fail(f"invalid source revision: {revision!r}") + + +def require_plain_file(path: Path, label: str) -> None: + if path.is_symlink() or not path.is_file(): + fail(f"{label} must be a regular non-symlink file: {path}") + + +def portable_filename_key(filename: str, label: str = "asset filename") -> str: + """Validate one name on Linux, macOS, and Windows and return its alias key.""" + + if ( + not PORTABLE_FILENAME_PATTERN.fullmatch(filename) + or filename.endswith(".") + or filename.split(".", 1)[0].casefold() in WINDOWS_RESERVED_STEMS + ): + fail(f"unsafe or non-portable {label}: {filename!r}") + return filename.casefold() + + +def require_unique_portable_names(names: list[str], label: str) -> None: + seen: set[str] = set() + for name in names: + key = portable_filename_key(name, label) + if key in seen: + fail(f"duplicate or case-colliding {label}: {name!r}") + seen.add(key) + + +def require_empty_output(output_dir: Path) -> None: + if output_dir.exists(): + if output_dir.is_symlink() or not output_dir.is_dir(): + fail(f"output path is not a plain directory: {output_dir}") + if any(output_dir.iterdir()): + fail(f"output directory must be empty: {output_dir}") + else: + output_dir.mkdir(parents=True) + + +def canonical_json(data: object) -> str: + return json.dumps(data, indent=2, sort_keys=True) + "\n" + + +def write_text_lf(path: Path, content: str) -> None: + with path.open("w", encoding="utf-8", newline="\n") as destination: + destination.write(content) + + +def create_bundle( + *, + producer: str, + release_tag: str, + revision: str, + assets: list[Path], + output_dir: Path, +) -> None: + require_release_identity(release_tag, revision) + if not PRODUCER_PATTERN.fullmatch(producer): + fail(f"invalid producer name: {producer!r}") + policy = PRODUCER_POLICIES.get(producer) + if policy is None: + fail(f"producer is not in the release policy: {producer}") + if len(assets) != policy.asset_count: + fail( + f"{producer} must emit exactly {policy.asset_count} asset(s); " + f"received {len(assets)}" + ) + + reserved_names = { + portable_filename_key(METADATA_NAME), + portable_filename_key(CHECKSUMS_NAME), + } + asset_names: set[str] = set() + for asset in assets: + require_plain_file(asset, "release asset") + name_key = portable_filename_key(asset.name) + if asset.suffix.lower() != policy.asset_suffix: + fail(f"{producer} asset must end in {policy.asset_suffix}: {asset.name}") + if asset.stat().st_size <= 0: + fail(f"{producer} asset must not be empty: {asset.name}") + if name_key in reserved_names or name_key in asset_names: + fail(f"duplicate or reserved asset name: {asset.name}") + asset_names.add(name_key) + + require_empty_output(output_dir) + manifest_assets = [] + checksum_lines = [] + for asset in sorted(assets, key=lambda candidate: candidate.name): + destination = output_dir / asset.name + shutil.copyfile(asset, destination) + digest = sha256(destination) + size = destination.stat().st_size + manifest_assets.append( + {"filename": destination.name, "sha256": digest, "size": size} + ) + checksum_lines.append(f"{digest} {destination.name}\n") + + metadata = { + "architecture": policy.architecture, + "assets": manifest_assets, + "platform": policy.platform, + "producer": producer, + "release_tag": release_tag, + "revision": revision, + "schema_version": SCHEMA_VERSION, + } + write_text_lf(output_dir / METADATA_NAME, canonical_json(metadata)) + write_text_lf(output_dir / CHECKSUMS_NAME, "".join(checksum_lines)) + + +def load_json(path: Path, label: str) -> dict: + require_plain_file(path, label) + + def unique_object(pairs: list[tuple[str, object]]) -> dict: + value = {} + for key, item in pairs: + if key in value: + fail(f"{label} contains duplicate JSON key: {key!r}") + value[key] = item + return value + + try: + value = json.loads( + path.read_text(encoding="utf-8"), object_pairs_hook=unique_object + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + fail(f"could not parse {label} {path}: {exc}") + if not isinstance(value, dict): + fail(f"{label} must contain a JSON object: {path}") + return value + + +def load_checksums(path: Path) -> dict[str, str]: + require_plain_file(path, "checksum file") + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError) as exc: + fail(f"could not read checksum file {path}: {exc}") + checksums: dict[str, str] = {} + checksum_keys: set[str] = set() + for line in lines: + parts = line.split(" ", 1) + if len(parts) != 2 or not SHA256_PATTERN.fullmatch(parts[0]): + fail(f"malformed checksum line in {path}: {line!r}") + filename = parts[1] + filename_key = portable_filename_key(filename, "checksum filename") + if filename_key in checksum_keys: + fail(f"unsafe or duplicate checksum filename in {path}: {filename!r}") + checksum_keys.add(filename_key) + checksums[filename] = parts[0] + return checksums + + +def verify_bundle( + *, + bundle_dir: Path, + producer: str, + release_tag: str, + revision: str, +) -> list[Path]: + if bundle_dir.is_symlink() or not bundle_dir.is_dir(): + fail(f"missing producer bundle for {producer}: {bundle_dir}") + policy = PRODUCER_POLICIES.get(producer) + if policy is None: + fail(f"required producer is not in the release policy: {producer}") + + entries = list(bundle_dir.iterdir()) + if any(entry.is_symlink() or not entry.is_file() for entry in entries): + fail(f"producer bundle contains a symlink or nested path: {bundle_dir}") + require_unique_portable_names( + [entry.name for entry in entries], "producer bundle filename" + ) + + metadata = load_json(bundle_dir / METADATA_NAME, "release metadata") + metadata_fields = { + "architecture", + "assets", + "platform", + "producer", + "release_tag", + "revision", + "schema_version", + } + if set(metadata) != metadata_fields: + fail(f"{producer} metadata fields do not exactly match schema version 1") + expected_identity = { + "architecture": policy.architecture, + "platform": policy.platform, + "producer": producer, + "release_tag": release_tag, + "revision": revision, + "schema_version": SCHEMA_VERSION, + } + for key, expected in expected_identity.items(): + if metadata.get(key) != expected: + fail( + f"{producer} metadata {key!r} is {metadata.get(key)!r}; " + f"expected {expected!r}" + ) + + manifest_assets = metadata.get("assets") + if ( + not isinstance(manifest_assets, list) + or len(manifest_assets) != policy.asset_count + ): + fail(f"{producer} metadata has the wrong number of assets") + checksums = load_checksums(bundle_dir / CHECKSUMS_NAME) + expected_files = {METADATA_NAME, CHECKSUMS_NAME} + verified_assets = [] + manifest_names: set[str] = set() + manifest_keys: set[str] = set() + for item in manifest_assets: + if not isinstance(item, dict): + fail(f"{producer} metadata contains a non-object asset entry") + if set(item) != {"filename", "sha256", "size"}: + fail(f"{producer} asset metadata fields do not exactly match the schema") + filename = item.get("filename") + digest = item.get("sha256") + size = item.get("size") + if not isinstance(filename, str): + fail(f"{producer} metadata contains an unsafe asset filename: {filename!r}") + filename_key = portable_filename_key(filename) + if filename_key in manifest_keys: + fail(f"{producer} metadata contains duplicate asset {filename}") + if Path(filename).suffix.lower() != policy.asset_suffix: + fail(f"{producer} asset has the wrong suffix: {filename}") + if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest): + fail(f"{producer} metadata has an invalid SHA-256 for {filename}") + if not isinstance(size, int) or isinstance(size, bool) or size <= 0: + fail(f"{producer} metadata has an invalid size for {filename}") + + asset = bundle_dir / filename + require_plain_file(asset, "release asset") + if asset.stat().st_size != size: + fail(f"{producer} asset size does not match metadata: {filename}") + if sha256(asset) != digest: + fail(f"{producer} asset checksum does not match metadata: {filename}") + if checksums.get(filename) != digest: + fail( + f"{producer} asset checksum does not match {CHECKSUMS_NAME}: {filename}" + ) + manifest_names.add(filename) + manifest_keys.add(filename_key) + expected_files.add(filename) + verified_assets.append(asset) + + if set(checksums) != manifest_names: + fail(f"{producer} checksum file does not exactly match its metadata assets") + if {entry.name for entry in entries} != expected_files: + fail(f"{producer} bundle contains missing or unexpected files") + return verified_assets + + +def verify_bundles( + *, + input_root: Path, + required_producers: list[str], + release_tag: str, + revision: str, + output_dir: Path, +) -> None: + require_release_identity(release_tag, revision) + if not required_producers or len(set(required_producers)) != len( + required_producers + ): + fail("required producers must be a non-empty unique list") + policy_producers = set(PRODUCER_POLICIES) + if set(required_producers) != policy_producers: + fail( + "required producers do not exactly match the release policy: " + f"received {sorted(required_producers)!r}, " + f"expected {sorted(policy_producers)!r}" + ) + if input_root.is_symlink() or not input_root.is_dir(): + fail(f"release input root is missing or unsafe: {input_root}") + + producer_dirs = list(input_root.iterdir()) + if any(path.is_symlink() or not path.is_dir() for path in producer_dirs): + fail(f"release input contains a non-directory producer entry: {input_root}") + if {path.name for path in producer_dirs} != set(required_producers): + fail("downloaded producer set does not exactly match the required producer set") + + require_empty_output(output_dir) + release_assets: list[dict] = [] + output_names = {portable_filename_key("release-index.json")} + for producer in sorted(required_producers): + bundle = input_root / producer + verified_assets = verify_bundle( + bundle_dir=bundle, + producer=producer, + release_tag=release_tag, + revision=revision, + ) + publish_files = [ + *verified_assets, + bundle / METADATA_NAME, + bundle / CHECKSUMS_NAME, + ] + for source in publish_files: + if source.name == METADATA_NAME: + destination_name = f"{producer}-{METADATA_NAME}" + elif source.name == CHECKSUMS_NAME: + destination_name = f"{producer}-{CHECKSUMS_NAME}" + else: + destination_name = source.name + destination_key = portable_filename_key( + destination_name, "published release filename" + ) + if destination_key in output_names: + fail( + f"release producers collide on published filename: {destination_name}" + ) + output_names.add(destination_key) + destination = output_dir / destination_name + shutil.copyfile(source, destination) + release_assets.append( + { + "filename": destination_name, + "sha256": sha256(destination), + "size": destination.stat().st_size, + } + ) + + release_index = { + "assets": sorted(release_assets, key=lambda item: item["filename"]), + "producers": sorted(required_producers), + "release_tag": release_tag, + "revision": revision, + "schema_version": SCHEMA_VERSION, + } + index_path = output_dir / "release-index.json" + write_text_lf(index_path, canonical_json(release_index)) + + +def require_draft_release_identity( + *, + data: dict, + release_tag: str, + revision: str, + expected_release_id: int | None = None, +) -> int: + """Bind one draft release to its immutable ID, tag, and source revision.""" + + require_release_identity(release_tag, revision) + if data.get("tag_name") != release_tag: + fail("draft GitHub Release is attached to the wrong tag") + if data.get("draft") is not True: + fail("GitHub Release must remain a draft until its assets are verified") + release_id = data.get("id") + if ( + not isinstance(release_id, int) + or isinstance(release_id, bool) + or release_id <= 0 + ): + fail("draft GitHub Release has an invalid immutable release ID") + if expected_release_id is not None and release_id != expected_release_id: + fail("draft GitHub Release ID does not match the release created by this run") + if data.get("target_commitish") != revision: + fail("draft GitHub Release is attached to the wrong source revision") + source_marker = f"Source revision: {revision}" + body = data.get("body") + if not isinstance(body, str) or not ( + body == source_marker or body.startswith(source_marker + "\n") + ): + fail("draft GitHub Release is missing its source revision marker") + return release_id + + +def verify_github_release( + *, + release_json: Path, + verified_dir: Path, + release_tag: str, + revision: str, + expected_release_id: int, +) -> None: + data = load_json(release_json, "GitHub release response") + require_draft_release_identity( + data=data, + release_tag=release_tag, + revision=revision, + expected_release_id=expected_release_id, + ) + + local_files = {} + local_keys: set[str] = set() + if verified_dir.is_symlink() or not verified_dir.is_dir(): + fail(f"verified release directory is missing or unsafe: {verified_dir}") + for path in verified_dir.iterdir(): + require_plain_file(path, "verified release file") + filename_key = portable_filename_key(path.name, "verified release filename") + if filename_key in local_keys: + fail(f"verified release contains case-colliding filename: {path.name}") + local_keys.add(filename_key) + size = path.stat().st_size + if size <= 0: + fail(f"verified release file must not be empty: {path.name}") + local_files[path.name] = {"sha256": sha256(path), "size": size} + + remote_files = {} + remote_keys: set[str] = set() + assets = data.get("assets") + if not isinstance(assets, list): + fail("GitHub release response has no asset list") + for asset in assets: + if not isinstance(asset, dict): + fail("GitHub release response contains a non-object asset") + name = asset.get("name") + size = asset.get("size") + state = asset.get("state") + if not isinstance(name, str) or not isinstance(size, int): + fail("GitHub release response contains invalid asset metadata") + filename_key = portable_filename_key(name, "GitHub release filename") + if state != "uploaded": + fail(f"GitHub release asset did not finish uploading: {name!r}") + if filename_key in remote_keys: + fail(f"GitHub release contains a duplicate asset: {name}") + remote_keys.add(filename_key) + local = local_files.get(name) + if local is None or size != local["size"]: + fail( + "draft GitHub Release assets do not exactly match the verified local files" + ) + digest = asset.get("digest") + if digest != f"sha256:{local['sha256']}": + fail(f"GitHub release asset digest does not match: {name}") + remote_files[name] = {"sha256": local["sha256"], "size": size} + if remote_files != local_files: + fail( + "draft GitHub Release assets do not exactly match the verified local files" + ) + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + commands = root.add_subparsers(dest="command", required=True) + + create = commands.add_parser("create", help="create one producer bundle") + create.add_argument("--producer", required=True) + create.add_argument("--release-tag", required=True) + create.add_argument("--revision", required=True) + create.add_argument("--asset", action="append", required=True, type=Path) + create.add_argument("--output-dir", required=True, type=Path) + + verify = commands.add_parser("verify", help="verify all required producer bundles") + verify.add_argument("--input-root", required=True, type=Path) + verify.add_argument("--required-producer", action="append", required=True) + verify.add_argument("--release-tag", required=True) + verify.add_argument("--revision", required=True) + verify.add_argument("--output-dir", required=True, type=Path) + + verify_release = commands.add_parser( + "verify-github-release", help="verify an uploaded draft before publication" + ) + verify_release.add_argument("--release-json", required=True, type=Path) + verify_release.add_argument("--verified-dir", required=True, type=Path) + verify_release.add_argument("--release-tag", required=True) + verify_release.add_argument("--revision", required=True) + verify_release.add_argument("--expected-release-id", required=True, type=int) + + verify_identity = commands.add_parser( + "verify-draft-identity", + help="verify a draft release identity and print its immutable ID", + ) + verify_identity.add_argument("--release-json", required=True, type=Path) + verify_identity.add_argument("--release-tag", required=True) + verify_identity.add_argument("--revision", required=True) + verify_identity.add_argument("--expected-release-id", type=int) + return root + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + try: + if args.command == "create": + create_bundle( + producer=args.producer, + release_tag=args.release_tag, + revision=args.revision, + assets=args.asset, + output_dir=args.output_dir, + ) + elif args.command == "verify": + verify_bundles( + input_root=args.input_root, + required_producers=args.required_producer, + release_tag=args.release_tag, + revision=args.revision, + output_dir=args.output_dir, + ) + elif args.command == "verify-github-release": + verify_github_release( + release_json=args.release_json, + verified_dir=args.verified_dir, + release_tag=args.release_tag, + revision=args.revision, + expected_release_id=args.expected_release_id, + ) + else: + data = load_json(args.release_json, "GitHub release response") + release_id = require_draft_release_identity( + data=data, + release_tag=args.release_tag, + revision=args.revision, + expected_release_id=args.expected_release_id, + ) + print(release_id) + except ArtifactError as exc: + print(f"release artifact: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/test_release_artifact.py b/scripts/tests/test_release_artifact.py new file mode 100644 index 0000000..98d438e --- /dev/null +++ b/scripts/tests/test_release_artifact.py @@ -0,0 +1,379 @@ +import importlib.util +import json +import re +import sys +import tempfile +import unittest +from unittest import mock +from pathlib import Path + + +MODULE_PATH = Path(__file__).parents[1] / "release_artifact.py" +REPO_ROOT = Path(__file__).parents[2] +SPEC = importlib.util.spec_from_file_location("release_artifact", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +release_artifact = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release_artifact +SPEC.loader.exec_module(release_artifact) + +REVISION = "a" * 40 +TAG = "v2026.08.7" + + +class ReleaseArtifactTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.asset = self.root / "LSDJ_2026.08.7_aarch64.dmg" + self.asset.write_bytes(b"verified dmg bytes") + + def tearDown(self): + self.temporary.cleanup() + + def create_bundle(self): + bundle = self.root / "incoming" / "macos-arm64" + release_artifact.create_bundle( + producer="macos-arm64", + release_tag=TAG, + revision=REVISION, + assets=[self.asset], + output_dir=bundle, + ) + return bundle + + def draft_release(self, assets, **updates): + data = { + "id": 12345, + "tag_name": TAG, + "target_commitish": REVISION, + "draft": True, + "body": f"Source revision: {REVISION}\n\nGenerated notes", + "assets": assets, + } + data.update(updates) + return data + + def test_create_and_verify_bundle(self): + bundle = self.create_bundle() + output = self.root / "verified" + + release_artifact.verify_bundles( + input_root=bundle.parent, + required_producers=["macos-arm64"], + release_tag=TAG, + revision=REVISION, + output_dir=output, + ) + + self.assertEqual( + {path.name for path in output.iterdir()}, + { + self.asset.name, + "macos-arm64-release-metadata.json", + "macos-arm64-SHA256SUMS.txt", + "release-index.json", + }, + ) + index = json.loads((output / "release-index.json").read_text()) + self.assertEqual(index["release_tag"], TAG) + self.assertEqual(index["revision"], REVISION) + self.assertEqual(index["producers"], ["macos-arm64"]) + + def test_tampered_asset_fails_closed(self): + bundle = self.create_bundle() + (bundle / self.asset.name).write_bytes(b"tampered") + + with self.assertRaisesRegex(release_artifact.ArtifactError, "size|checksum"): + release_artifact.verify_bundles( + input_root=bundle.parent, + required_producers=["macos-arm64"], + release_tag=TAG, + revision=REVISION, + output_dir=self.root / "verified", + ) + + def test_empty_installer_fails_closed(self): + self.asset.write_bytes(b"") + + with self.assertRaisesRegex( + release_artifact.ArtifactError, "must not be empty" + ): + self.create_bundle() + + def test_host_specific_or_ambiguous_asset_names_fail_closed(self): + for filename in ("LSDJ\\setup.dmg", "LSDJ\nsetup.dmg"): + with self.subTest(filename=filename): + with self.assertRaisesRegex( + release_artifact.ArtifactError, "non-portable" + ): + release_artifact.portable_filename_key(filename) + + def test_missing_required_producer_fails_closed(self): + incoming = self.root / "incoming" + incoming.mkdir() + + with self.assertRaisesRegex(release_artifact.ArtifactError, "producer set"): + release_artifact.verify_bundles( + input_root=incoming, + required_producers=["macos-arm64"], + release_tag=TAG, + revision=REVISION, + output_dir=self.root / "verified", + ) + + def test_unexpected_bundle_file_fails_closed(self): + bundle = self.create_bundle() + (bundle / "surprise.txt").write_text("not declared") + + with self.assertRaisesRegex(release_artifact.ArtifactError, "unexpected"): + release_artifact.verify_bundles( + input_root=bundle.parent, + required_producers=["macos-arm64"], + release_tag=TAG, + revision=REVISION, + output_dir=self.root / "verified", + ) + + def test_wrong_release_identity_fails_closed(self): + bundle = self.create_bundle() + + with self.assertRaisesRegex(release_artifact.ArtifactError, "release_tag"): + release_artifact.verify_bundles( + input_root=bundle.parent, + required_producers=["macos-arm64"], + release_tag="v2026.08.8", + revision=REVISION, + output_dir=self.root / "verified", + ) + + def test_required_producer_arguments_must_exactly_match_policy(self): + bundle = self.create_bundle() + windows_policy = release_artifact.ProducerPolicy( + platform="windows", + architecture="x86_64", + asset_suffix=".exe", + asset_count=1, + ) + + with mock.patch.dict( + release_artifact.PRODUCER_POLICIES, + {"windows-x64": windows_policy}, + ): + with self.assertRaisesRegex( + release_artifact.ArtifactError, "release policy" + ): + release_artifact.verify_bundles( + input_root=bundle.parent, + required_producers=["macos-arm64"], + release_tag=TAG, + revision=REVISION, + output_dir=self.root / "verified", + ) + + def test_draft_release_assets_must_match_exactly(self): + verified = self.root / "verified" + verified.mkdir() + (verified / "asset.dmg").write_bytes(b"one") + response = self.root / "release.json" + response.write_text( + json.dumps( + self.draft_release( + [ + { + "name": "asset.dmg", + "size": 3, + "state": "uploaded", + "digest": "sha256:" + + release_artifact.sha256(verified / "asset.dmg"), + } + ] + ) + ) + ) + + release_artifact.verify_github_release( + release_json=response, + verified_dir=verified, + release_tag=TAG, + revision=REVISION, + expected_release_id=12345, + ) + + data = json.loads(response.read_text()) + data["assets"][0]["size"] = 4 + response.write_text(json.dumps(data)) + with self.assertRaisesRegex(release_artifact.ArtifactError, "exactly match"): + release_artifact.verify_github_release( + release_json=response, + verified_dir=verified, + release_tag=TAG, + revision=REVISION, + expected_release_id=12345, + ) + + def test_github_digest_is_required(self): + verified = self.root / "verified" + verified.mkdir() + asset = verified / "asset.dmg" + asset.write_bytes(b"one") + response = self.root / "release.json" + + for digest_entry in ({}, {"digest": None}): + with self.subTest(digest_entry=digest_entry): + response.write_text( + json.dumps( + self.draft_release( + [ + { + "name": asset.name, + "size": asset.stat().st_size, + "state": "uploaded", + **digest_entry, + } + ] + ) + ) + ) + with self.assertRaisesRegex(release_artifact.ArtifactError, "digest"): + release_artifact.verify_github_release( + release_json=response, + verified_dir=verified, + release_tag=TAG, + revision=REVISION, + expected_release_id=12345, + ) + + def test_case_colliding_release_names_fail_closed(self): + with self.assertRaisesRegex(release_artifact.ArtifactError, "case-colliding"): + release_artifact.require_unique_portable_names( + ["LSDJ.dmg", "lsdj.DMG"], "release filename" + ) + + def test_github_digest_is_verified_when_present(self): + verified = self.root / "verified" + verified.mkdir() + asset = verified / "asset.dmg" + asset.write_bytes(b"one") + response = self.root / "release.json" + response.write_text( + json.dumps( + self.draft_release( + [ + { + "name": asset.name, + "size": asset.stat().st_size, + "state": "uploaded", + "digest": "sha256:" + "0" * 64, + } + ] + ) + ) + ) + + with self.assertRaisesRegex(release_artifact.ArtifactError, "digest"): + release_artifact.verify_github_release( + release_json=response, + verified_dir=verified, + release_tag=TAG, + revision=REVISION, + expected_release_id=12345, + ) + + def test_draft_identity_cannot_be_redirected_to_a_replacement(self): + replacement = self.draft_release([], id=67890) + + with self.assertRaisesRegex(release_artifact.ArtifactError, "Release ID"): + release_artifact.require_draft_release_identity( + data=replacement, + release_tag=TAG, + revision=REVISION, + expected_release_id=12345, + ) + + def test_draft_identity_requires_the_exact_source_revision(self): + for updates in ( + {"target_commitish": "b" * 40}, + {"body": "Source revision: " + "b" * 40}, + ): + with self.subTest(updates=updates): + with self.assertRaisesRegex( + release_artifact.ArtifactError, "source revision" + ): + release_artifact.require_draft_release_identity( + data=self.draft_release([], **updates), + release_tag=TAG, + revision=REVISION, + expected_release_id=12345, + ) + + def test_public_release_is_never_accepted_for_pre_publish_verification(self): + verified = self.root / "verified" + verified.mkdir() + response = self.root / "release.json" + response.write_text(json.dumps({"tag_name": TAG, "draft": False, "assets": []})) + + with self.assertRaisesRegex(release_artifact.ArtifactError, "remain a draft"): + release_artifact.verify_github_release( + release_json=response, + verified_dir=verified, + release_tag=TAG, + revision=REVISION, + expected_release_id=12345, + ) + + +class WorkflowContractTest(unittest.TestCase): + def test_release_workflow_keeps_one_least_privilege_publisher(self): + workflow = (REPO_ROOT / ".github/workflows/macos-release.yml").read_text() + + self.assertEqual(workflow.count("contents: write"), 1) + self.assertEqual(len(re.findall(r"^ publish:$", workflow, re.MULTILINE)), 1) + self.assertIn("needs.produce_macos.result == 'success'", workflow) + self.assertIn("--required-producer macos-arm64", workflow) + self.assertRegex(workflow, r"(?m)^on:\n push:\n tags:$") + self.assertNotIn("pull_request:", workflow) + self.assertNotIn("workflow_dispatch:", workflow) + + def test_release_is_verified_before_the_draft_becomes_public(self): + workflow = (REPO_ROOT / ".github/workflows/macos-release.yml").read_text() + + create = workflow.index("CREATE_RESPONSE=") + verify = workflow.index("verify-github-release") + publish = workflow.index("--method PATCH") + self.assertLess(create, verify) + self.assertLess(verify, publish) + + def test_failed_draft_cleanup_is_bound_to_the_created_release_id(self): + workflow = (REPO_ROOT / ".github/workflows/macos-release.yml").read_text() + cleanup = workflow[ + workflow.index("cleanup_draft()") : workflow.index("trap cleanup_draft") + ] + + self.assertIn("releases/$DRAFT_RELEASE_ID", cleanup) + self.assertIn("--expected-release-id", cleanup) + self.assertIn("verify-draft-identity", cleanup) + self.assertIn("--method DELETE", cleanup) + self.assertNotIn("releases/tags/", cleanup) + self.assertNotIn("gh release delete", cleanup) + + def test_official_actions_are_immutably_pinned(self): + for relative in ( + ".github/workflows/ci.yml", + ".github/workflows/macos-release.yml", + ): + workflow = (REPO_ROOT / relative).read_text() + uses = re.findall(r"^\s+uses: ([^\s#]+)", workflow, re.MULTILINE) + self.assertTrue(uses) + for action in uses: + with self.subTest(workflow=relative, action=action): + self.assertRegex(action, r"^[^@]+@[0-9a-f]{40}$") + + def test_windows_ci_has_no_forced_bash_steps(self): + workflow = (REPO_ROOT / ".github/workflows/ci.yml").read_text() + + self.assertNotIn("shell: bash", workflow) + self.assertIn("if: runner.os == 'Linux'", workflow) + + +if __name__ == "__main__": + unittest.main()