diff --git a/.github/workflows/build-baseline.yml b/.github/workflows/build-baseline.yml index abec57b6b..7a06b0652 100644 --- a/.github/workflows/build-baseline.yml +++ b/.github/workflows/build-baseline.yml @@ -21,8 +21,21 @@ env: GIT_CONFIG_VALUE_0: develop jobs: + release-identity: + name: release-identity + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Verify release identity + run: python3 scripts/checks/verify_release_identity.py + build-windows-native: name: build / windows / amd64 + needs: release-identity runs-on: windows-2025 strategy: fail-fast: false @@ -122,6 +135,7 @@ jobs: build-windows-arm64: name: build / windows / arm64 + needs: release-identity runs-on: windows-11-arm strategy: fail-fast: false @@ -232,6 +246,7 @@ jobs: build-macos-native: name: build / macos / amd64 + needs: release-identity runs-on: macos-15-intel strategy: fail-fast: false @@ -294,6 +309,7 @@ jobs: build-macos-arm64: name: build / macos / arm64 + needs: release-identity runs-on: macos-15 strategy: fail-fast: false @@ -369,6 +385,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: + - release-identity - gate-windows - gate-macos permissions: diff --git a/scripts/checks/verify_release_identity.py b/scripts/checks/verify_release_identity.py new file mode 100644 index 000000000..8b5d1be7e --- /dev/null +++ b/scripts/checks/verify_release_identity.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Fail closed when BandScope release-version projections disagree. + +Security Notes: +- ``repository_root`` is an already-selected repository boundary; this guard + reads only the fixed ``VERSION``, ``package.json``, and Tauri configuration + paths beneath it and never follows metadata-provided file paths. +- VERSION and JSON fields are validated as exact, non-empty, trimmed strings + before comparison; malformed text or JSON fails closed without echoing values. +- The guard has no network, filesystem-write, subprocess, update, credential, + signing, or publication authority. It only returns a version or a failure. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +def _read_json_object(metadata_path: Path) -> dict[str, Any]: + """Read one release metadata document and require a JSON object root.""" + try: + metadata_document = json.loads(metadata_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as metadata_error: + raise ValueError( + f"could not read release metadata: {metadata_path.name}" + ) from metadata_error + if not isinstance(metadata_document, dict): + raise ValueError(f"release metadata must be an object: {metadata_path.name}") + return metadata_document + + +def _required_string( + metadata_document: dict[str, Any], field_name: str, source_name: str +) -> str: + """Return a non-empty string field without coercing malformed metadata.""" + field_value = metadata_document.get(field_name) + if ( + not isinstance(field_value, str) + or not field_value.strip() + or field_value != field_value.strip() + ): + raise ValueError( + f"{source_name} {field_name} must be a non-empty trimmed string" + ) + return field_value + + +def verify_release_identity( + repository_root: Path, release_tag: str | None = None +) -> str: + """Verify package, Tauri, and optional tag versions against ``VERSION``.""" + try: + version_text = (repository_root / "VERSION").read_text(encoding="utf-8") + except (OSError, UnicodeError) as identity_error: + raise ValueError("could not read authoritative VERSION") from identity_error + + version_lines = version_text.splitlines() + if ( + len(version_lines) != 1 + or not version_lines[0] + or version_lines[0] != version_lines[0].strip() + or version_text != f"{version_lines[0]}\n" + ): + raise ValueError("VERSION must contain exactly one non-empty version line") + release_version = version_lines[0] + + package_document = _read_json_object(repository_root / "package.json") + tauri_document = _read_json_object( + repository_root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json" + ) + + package_version = _required_string( + package_document, "version", "package.json" + ) + tauri_version = _required_string( + tauri_document, "version", "tauri.conf.json" + ) + if package_version != release_version: + raise ValueError("package.json version does not match VERSION") + if tauri_version != release_version: + raise ValueError("tauri.conf.json version does not match VERSION") + + if release_tag is not None and release_tag != f"v{release_version}": + raise ValueError("release tag does not match VERSION") + + return release_version + + +def main() -> int: + """Run the release identity gate for repository and tag-triggered workflows.""" + release_tag = ( + os.environ.get("GITHUB_REF_NAME") + if os.environ.get("GITHUB_REF_TYPE") == "tag" + else None + ) + try: + release_version = verify_release_identity( + _REPOSITORY_ROOT, release_tag=release_tag + ) + except ValueError as identity_error: + print(f"release identity check failed: {identity_error}", file=sys.stderr) + return 1 + print(f"BandScope release identity verified: v{release_version}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/checks/verify_release_platform_trust.py b/scripts/checks/verify_release_platform_trust.py new file mode 100644 index 000000000..6d6d09cb9 --- /dev/null +++ b/scripts/checks/verify_release_platform_trust.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +"""Verify platform-native trust on BandScope release artifacts before publication. + +Security Notes: + This verifier has read/execute authority only over repository-built release outputs and + fixed platform trust tools. Artifact paths are passed as subprocess arguments rather than + interpolated into shell text. Publisher identity comes from repository configuration, is + bounded, and is compared exactly. Command output is parsed only for the minimum signature + status/team fields and is never promoted into a filesystem path or command. Any missing + artifact, missing identity, malformed trust output, unsigned artifact, unexpected signer, + failed Gatekeeper assessment, or missing notarization ticket fails closed. +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any + +CommandRunner = Callable[..., Any] +_WINDOWS_SUFFIXES = {".exe", ".msi"} +_APPLE_TEAM_ID_PATTERN = re.compile(r"^[A-Z0-9]{10}$") +_WINDOWS_SIGNATURE_SCRIPT = r""" +$signature = Get-AuthenticodeSignature -LiteralPath $args[0] +$subject = $null +if ($null -ne $signature.SignerCertificate) { + $subject = $signature.SignerCertificate.Subject +} +[pscustomobject]@{ + Status = [string]$signature.Status + Subject = $subject +} | ConvertTo-Json -Compress +""".strip() + + +def _configured_identity(label: str, value: str, *, max_length: int) -> str: + """Return a bounded single-line configured signer identity or fail closed.""" + if not value or value != value.strip() or len(value) > max_length: + raise ValueError(f"{label} must be configured exactly for release verification") + if any(character in value for character in "\r\n\x00"): + raise ValueError(f"{label} must be configured exactly for release verification") + return value + + +def _regular_files(root: Path, suffixes: set[str], missing_label: str) -> list[Path]: + """Return direct regular non-link release files with one of the allowed suffixes.""" + if not root.is_dir() or root.is_symlink(): + raise ValueError(f"{missing_label} directory is unavailable") + matches: list[Path] = [] + for candidate in sorted(root.iterdir()): + if candidate.suffix.lower() not in suffixes: + continue + if candidate.is_symlink() or not candidate.is_file(): + raise ValueError(f"{missing_label} must be a regular non-link file") + matches.append(candidate) + if not matches: + raise ValueError(f"no {missing_label} was produced") + return matches + + +def _application_bundles(bundle_root: Path) -> list[Path]: + """Return direct regular macOS application bundles from the Tauri bundle directory.""" + if not bundle_root.is_dir() or bundle_root.is_symlink(): + raise ValueError("macOS application bundle directory is unavailable") + applications: list[Path] = [] + for candidate in sorted(bundle_root.glob("*.app")): + if candidate.is_symlink() or not candidate.is_dir(): + raise ValueError("macOS application bundle must be a regular non-link directory") + applications.append(candidate) + if not applications: + raise ValueError("no macOS application bundle was produced") + return applications + + +def _run_command( + command: Sequence[str], + *, + runner: CommandRunner, + failure_message: str, +) -> Any: + """Run one fixed trust command and translate any nonzero result into a bounded error.""" + try: + result = runner( + list(command), + capture_output=True, + text=True, + check=False, + ) + except OSError as command_error: + raise ValueError(failure_message) from command_error + if result.returncode != 0: + raise ValueError(failure_message) + return result + + +def verify_windows_artifacts( + artifact_root: Path, + expected_publisher_subject: str, + *, + runner: CommandRunner = subprocess.run, +) -> list[Path]: + """Verify Authenticode validity and the exact approved publisher for Windows installers.""" + expected_subject = _configured_identity( + "Windows publisher subject", expected_publisher_subject, max_length=512 + ) + installers = _regular_files( + artifact_root, _WINDOWS_SUFFIXES, "Windows release installer" + ) + for installer in installers: + result = _run_command( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + _WINDOWS_SIGNATURE_SCRIPT, + str(installer), + ], + runner=runner, + failure_message="Windows release installer does not have a valid Authenticode signature", + ) + try: + signature = json.loads(result.stdout) + except (json.JSONDecodeError, TypeError) as output_error: + raise ValueError( + "Windows release installer does not have a valid Authenticode signature" + ) from output_error + if not isinstance(signature, dict) or signature.get("Status") != "Valid": + raise ValueError( + "Windows release installer does not have a valid Authenticode signature" + ) + if signature.get("Subject") != expected_subject: + raise ValueError("Windows release installer is not signed by the approved Windows publisher") + return installers + + +def _macos_team_identifier(details: str) -> str | None: + """Extract the exact TeamIdentifier line from codesign display output.""" + for line in details.splitlines(): + if line.startswith("TeamIdentifier="): + return line.removeprefix("TeamIdentifier=") + return None + + +def verify_macos_artifacts( + artifact_root: Path, + bundle_root: Path, + expected_team_id: str, + *, + runner: CommandRunner = subprocess.run, +) -> tuple[list[Path], list[Path]]: + """Verify signed app bundles and stapled, Gatekeeper-accepted macOS disk images.""" + team_id = _configured_identity("Apple Team ID", expected_team_id, max_length=10) + if _APPLE_TEAM_ID_PATTERN.fullmatch(team_id) is None: + raise ValueError("Apple Team ID must be configured exactly for release verification") + + applications = _application_bundles(bundle_root) + disk_images = _regular_files(artifact_root, {".dmg"}, "macOS release disk image") + + for application in applications: + _run_command( + ["codesign", "--verify", "--deep", "--strict", str(application)], + runner=runner, + failure_message="macOS application bundle does not have a valid code signature", + ) + details = _run_command( + ["codesign", "--display", "--verbose=4", str(application)], + runner=runner, + failure_message="macOS application bundle signing identity could not be verified", + ) + signer_details = f"{details.stdout}\n{details.stderr}" + if _macos_team_identifier(signer_details) != team_id: + raise ValueError("macOS application bundle is not signed by the approved Apple Team ID") + + for disk_image in disk_images: + _run_command( + ["xcrun", "stapler", "validate", str(disk_image)], + runner=runner, + failure_message="macOS release disk image does not contain a valid notarization ticket", + ) + _run_command( + [ + "spctl", + "--assess", + "--type", + "open", + "--context", + "context:primary-signature", + "--verbose=2", + str(disk_image), + ], + runner=runner, + failure_message="macOS release disk image is not accepted by Gatekeeper", + ) + return applications, disk_images + + +def _parser() -> argparse.ArgumentParser: + """Build the command-line contract used by release jobs.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("platform", choices=("windows", "macos")) + parser.add_argument("artifact_root", type=Path) + parser.add_argument("--bundle-root", type=Path) + parser.add_argument("--expected-identity", required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Verify one platform's release outputs and return a fail-closed process status.""" + arguments = _parser().parse_args(argv) + try: + if arguments.platform == "windows": + verified = verify_windows_artifacts( + arguments.artifact_root, arguments.expected_identity + ) + print(f"Verified {len(verified)} Windows release installer(s).") + return 0 + if arguments.bundle_root is None: + raise ValueError("macOS release verification requires --bundle-root") + applications, disk_images = verify_macos_artifacts( + arguments.artifact_root, + arguments.bundle_root, + arguments.expected_identity, + ) + print( + "Verified " + f"{len(applications)} macOS application bundle(s) and " + f"{len(disk_images)} notarized disk image(s)." + ) + return 0 + except ValueError as verification_error: + print(f"Release platform trust verification failed: {verification_error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/harness/quickcheck.sh b/scripts/harness/quickcheck.sh index f2b87e4e8..22ba2b31a 100755 --- a/scripts/harness/quickcheck.sh +++ b/scripts/harness/quickcheck.sh @@ -9,6 +9,7 @@ python3 scripts/checks/verify_security_notes.py python3 scripts/checks/security_gates.py python3 scripts/checks/verify_supply_chain.py python3 scripts/checks/verify_github_bootstrap_policy.py +python3 scripts/checks/verify_release_identity.py npm run lint npm run typecheck npm run test diff --git a/scripts/release/package_desktop_artifact.py b/scripts/release/package_desktop_artifact.py index 5617ce760..7a7b77601 100644 --- a/scripts/release/package_desktop_artifact.py +++ b/scripts/release/package_desktop_artifact.py @@ -7,8 +7,14 @@ import platform import re import shutil +import subprocess +import sys from collections import Counter +from collections.abc import Callable, Sequence from pathlib import Path +from typing import Any + +CommandRunner = Callable[..., Any] def sha256_file(path: Path) -> str: @@ -104,8 +110,72 @@ def find_installer_packages(repo_root: Path) -> list[Path]: return sorted(installers) +def _is_tag_release() -> bool: + """Return whether this package operation belongs to a version-tag release build.""" + return os.environ.get("GITHUB_REF", "").startswith("refs/tags/v") + + +def _platform_trust_command(repo_root: Path, output_dir: Path) -> Sequence[str]: + """Build the fixed verifier command for the selected tagged release target.""" + verifier_path = repo_root / "scripts" / "checks" / "verify_release_platform_trust.py" + target_platform, _ = resolved_artifact_target() + if target_platform == "windows": + return [ + sys.executable, + str(verifier_path), + "windows", + str(output_dir), + "--expected-identity", + os.environ.get("BANDSCOPE_WINDOWS_PUBLISHER_SUBJECT", ""), + ] + if target_platform == "macos": + target_triple = os.environ.get("BANDSCOPE_TARGET_TRIPLE", "") + if not target_triple: + raise RuntimeError("Tagged macOS release packaging requires BANDSCOPE_TARGET_TRIPLE") + bundle_root = ( + repo_root + / "apps" + / "desktop" + / "src-tauri" + / "target" + / target_triple + / "release" + / "bundle" + / "macos" + ) + return [ + sys.executable, + str(verifier_path), + "macos", + str(output_dir), + "--bundle-root", + str(bundle_root), + "--expected-identity", + os.environ.get("BANDSCOPE_APPLE_TEAM_ID", ""), + ] + raise RuntimeError("Tagged release packaging is unsupported on this platform") + + +def verify_tag_platform_trust( + repo_root: Path, + output_dir: Path, + *, + runner: CommandRunner = subprocess.run, +) -> None: + """Block tagged artifact publication unless platform-native trust evidence passes.""" + if not _is_tag_release(): + return + command = _platform_trust_command(repo_root, output_dir) + try: + result = runner(list(command), check=False) + except OSError as verification_error: + raise RuntimeError("Platform release trust verification could not run") from verification_error + if result.returncode != 0: + raise RuntimeError("Platform release trust verification failed") + + def main() -> int: - """Find the built installer packages, rename them, and calculate checksums.""" + """Find the built installer packages, rename them, calculate checksums, and verify tag trust.""" repo_root = Path(__file__).resolve().parents[2] output_dir = repo_root / "artifacts" output_dir.mkdir(parents=True, exist_ok=True) @@ -155,6 +225,7 @@ def main() -> int: print(f"Packaged {installer_path.name} to artifacts/{archive_name}") + verify_tag_platform_trust(repo_root, output_dir) return 0 diff --git a/services/analysis-engine/tests/test_release_platform_trust.py b/services/analysis-engine/tests/test_release_platform_trust.py new file mode 100644 index 000000000..f3e0ca207 --- /dev/null +++ b/services/analysis-engine/tests/test_release_platform_trust.py @@ -0,0 +1,309 @@ +"""Platform signature and notarization gates for BandScope release artifacts.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_platform_trust.py" +_PACKAGER_PATH = _REPOSITORY_ROOT / "scripts" / "release" / "package_desktop_artifact.py" +_BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" + + +def _load_module(path: Path, module_name: str) -> ModuleType: + """Load one repository-owned executable module without adding a package boundary.""" + assert path.is_file(), f"release boundary module is missing: {path.name}" + module_spec = importlib.util.spec_from_file_location(module_name, path) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _load_guard() -> ModuleType: + """Load the executable release-trust guard from its repository path.""" + return _load_module(_GUARD_PATH, "verify_release_platform_trust") + + +def _load_packager() -> ModuleType: + """Load the release packager that owns the tag-publication trust call.""" + return _load_module(_PACKAGER_PATH, "package_desktop_artifact_trust") + + +def _command_result( + returncode: int = 0, stdout: str = "", stderr: str = "" +) -> SimpleNamespace: + """Build the subprocess result shape consumed by the trust verifier.""" + return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr) + + +def _workflow_job_block(workflow_text: str, job_name: str) -> str: + """Return one top-level GitHub Actions job without adding a YAML dependency.""" + job_marker = f" {job_name}:" + workflow_lines = workflow_text.splitlines() + job_start_index = workflow_lines.index(job_marker) + job_end_index = len(workflow_lines) + for line_index in range(job_start_index + 1, len(workflow_lines)): + workflow_line = workflow_lines[line_index] + if ( + workflow_line.startswith(" ") + and not workflow_line.startswith(" ") + and workflow_line.endswith(":") + ): + job_end_index = line_index + break + return "\n".join(workflow_lines[job_start_index:job_end_index]) + + +def test_windows_release_trust_requires_valid_exact_publisher(tmp_path: Path) -> None: + """Accept only valid Authenticode signatures from the configured publisher.""" + guard = _load_guard() + artifact_path = tmp_path / "bandscope.exe" + artifact_path.write_bytes(b"signed-installer-placeholder") + commands: list[list[str]] = [] + + def valid_runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + return _command_result( + stdout='{"Status":"Valid","Subject":"CN=ContextualWisdomLab"}' + ) + + verified = guard.verify_windows_artifacts( + tmp_path, "CN=ContextualWisdomLab", runner=valid_runner + ) + + assert verified == [artifact_path] + assert commands[0][0] == "pwsh" + assert str(artifact_path) == commands[0][-1] + + def unsigned_runner(command: list[str], **_: object) -> SimpleNamespace: + del command + return _command_result(stdout='{"Status":"NotSigned","Subject":null}') + + with pytest.raises(ValueError, match="valid Authenticode signature"): + guard.verify_windows_artifacts( + tmp_path, "CN=ContextualWisdomLab", runner=unsigned_runner + ) + + def wrong_publisher_runner(command: list[str], **_: object) -> SimpleNamespace: + del command + return _command_result( + stdout='{"Status":"Valid","Subject":"CN=Other Publisher"}' + ) + + with pytest.raises(ValueError, match="approved Windows publisher"): + guard.verify_windows_artifacts( + tmp_path, "CN=ContextualWisdomLab", runner=wrong_publisher_runner + ) + + +def test_windows_release_trust_fails_closed_without_identity_or_artifacts( + tmp_path: Path, +) -> None: + """Refuse a tag release when publisher authority or installers are absent.""" + guard = _load_guard() + + with pytest.raises(ValueError, match="Windows publisher subject"): + guard.verify_windows_artifacts(tmp_path, "") + + with pytest.raises(ValueError, match="Windows release installer"): + guard.verify_windows_artifacts(tmp_path, "CN=ContextualWisdomLab") + + +def test_macos_release_trust_requires_team_signature_and_stapled_ticket( + tmp_path: Path, +) -> None: + """Require Developer ID team identity plus offline notarization evidence.""" + guard = _load_guard() + artifact_root = tmp_path / "artifacts" + bundle_root = tmp_path / "bundle" / "macos" + artifact_root.mkdir() + app_path = bundle_root / "BandScope.app" + app_path.mkdir(parents=True) + dmg_path = artifact_root / "bandscope.dmg" + dmg_path.write_bytes(b"notarized-dmg-placeholder") + commands: list[list[str]] = [] + + def valid_runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + if command[:3] == ["codesign", "--display", "--verbose=4"]: + return _command_result(stderr="TeamIdentifier=ABCDE12345\n") + return _command_result() + + verified_apps, verified_dmgs = guard.verify_macos_artifacts( + artifact_root, bundle_root, "ABCDE12345", runner=valid_runner + ) + + assert verified_apps == [app_path] + assert verified_dmgs == [dmg_path] + assert ["codesign", "--verify", "--deep", "--strict", str(app_path)] in commands + assert ["xcrun", "stapler", "validate", str(dmg_path)] in commands + assert [ + "spctl", + "--assess", + "--type", + "open", + "--context", + "context:primary-signature", + "--verbose=2", + str(dmg_path), + ] in commands + + +def test_macos_release_trust_fails_closed_on_wrong_team_or_notarization( + tmp_path: Path, +) -> None: + """Reject an unexpected signing team and a DMG without valid notarization evidence.""" + guard = _load_guard() + artifact_root = tmp_path / "artifacts" + bundle_root = tmp_path / "bundle" / "macos" + artifact_root.mkdir() + app_path = bundle_root / "BandScope.app" + app_path.mkdir(parents=True) + dmg_path = artifact_root / "bandscope.dmg" + dmg_path.write_bytes(b"dmg-placeholder") + + def wrong_team_runner(command: list[str], **_: object) -> SimpleNamespace: + if command[:3] == ["codesign", "--display", "--verbose=4"]: + return _command_result(stderr="TeamIdentifier=ZZZZZ99999\n") + return _command_result() + + with pytest.raises(ValueError, match="approved Apple Team ID"): + guard.verify_macos_artifacts( + artifact_root, bundle_root, "ABCDE12345", runner=wrong_team_runner + ) + + def unstapled_runner(command: list[str], **_: object) -> SimpleNamespace: + if command[:3] == ["codesign", "--display", "--verbose=4"]: + return _command_result(stderr="TeamIdentifier=ABCDE12345\n") + if command[:3] == ["xcrun", "stapler", "validate"]: + return _command_result(returncode=1, stderr="ticket missing") + return _command_result() + + with pytest.raises(ValueError, match="notarization ticket"): + guard.verify_macos_artifacts( + artifact_root, bundle_root, "ABCDE12345", runner=unstapled_runner + ) + + +def test_macos_release_trust_fails_closed_without_identity_or_outputs( + tmp_path: Path, +) -> None: + """Refuse a macOS release when configured team authority or outputs are absent.""" + guard = _load_guard() + artifact_root = tmp_path / "artifacts" + bundle_root = tmp_path / "bundle" + artifact_root.mkdir() + bundle_root.mkdir() + + with pytest.raises(ValueError, match="Apple Team ID"): + guard.verify_macos_artifacts(artifact_root, bundle_root, "") + + with pytest.raises(ValueError, match="macOS application bundle"): + guard.verify_macos_artifacts(artifact_root, bundle_root, "ABCDE12345") + + +def test_tag_packager_invokes_windows_trust_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Bind Windows tag packaging to the native verifier before artifact upload.""" + packager = _load_packager() + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("BANDSCOPE_ARTIFACT_OS", "windows") + monkeypatch.setenv( + "BANDSCOPE_WINDOWS_PUBLISHER_SUBJECT", "CN=ContextualWisdomLab" + ) + commands: list[list[str]] = [] + + def runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + return _command_result() + + packager.verify_tag_platform_trust(tmp_path, tmp_path / "artifacts", runner=runner) + + assert len(commands) == 1 + assert commands[0][2:5] == [ + "windows", + str(tmp_path / "artifacts"), + "--expected-identity", + ] + assert commands[0][-1] == "CN=ContextualWisdomLab" + + +def test_tag_packager_invokes_macos_trust_guard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Bind macOS tag packaging to signature, team, and notarization verification.""" + packager = _load_packager() + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("BANDSCOPE_ARTIFACT_OS", "macos") + monkeypatch.setenv("BANDSCOPE_TARGET_TRIPLE", "aarch64-apple-darwin") + monkeypatch.setenv("BANDSCOPE_APPLE_TEAM_ID", "ABCDE12345") + commands: list[list[str]] = [] + + def runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + return _command_result() + + packager.verify_tag_platform_trust(tmp_path, tmp_path / "artifacts", runner=runner) + + expected_bundle_root = ( + tmp_path + / "apps" + / "desktop" + / "src-tauri" + / "target" + / "aarch64-apple-darwin" + / "release" + / "bundle" + / "macos" + ) + assert len(commands) == 1 + assert "macos" in commands[0] + assert str(expected_bundle_root) in commands[0] + assert commands[0][-1] == "ABCDE12345" + + +def test_tag_packager_is_fail_closed_and_non_tag_packaging_stays_build_only( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Reject failed release trust while leaving ordinary validation builds unsigned.""" + packager = _load_packager() + commands: list[list[str]] = [] + + def failing_runner(command: list[str], **_: object) -> SimpleNamespace: + commands.append(command) + return _command_result(returncode=1) + + monkeypatch.setenv("GITHUB_REF", "refs/heads/develop") + packager.verify_tag_platform_trust( + tmp_path, tmp_path / "artifacts", runner=failing_runner + ) + assert commands == [] + + monkeypatch.setenv("GITHUB_REF", "refs/tags/v1.2.3") + monkeypatch.setenv("BANDSCOPE_ARTIFACT_OS", "windows") + with pytest.raises(RuntimeError, match="Platform release trust verification failed"): + packager.verify_tag_platform_trust( + tmp_path, tmp_path / "artifacts", runner=failing_runner + ) + + +def test_tag_builds_package_before_artifact_upload() -> None: + """Keep immutable publication downstream of the packager-owned trust gate.""" + workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") + + for job_name in ( + "build-windows-native", + "build-windows-arm64", + "build-macos-native", + "build-macos-arm64", + ): + job_block = _workflow_job_block(workflow_text, job_name) + packaging_index = job_block.index("scripts/release/package_desktop_artifact.py") + upload_index = job_block.index("uses: actions/upload-artifact@") + assert packaging_index < upload_index diff --git a/services/analysis-engine/tests/test_release_version_identity.py b/services/analysis-engine/tests/test_release_version_identity.py new file mode 100644 index 000000000..67a587bcc --- /dev/null +++ b/services/analysis-engine/tests/test_release_version_identity.py @@ -0,0 +1,174 @@ +"""Release identity contracts for the packaged BandScope desktop application.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +_GUARD_PATH = _REPOSITORY_ROOT / "scripts" / "checks" / "verify_release_identity.py" +_BUILD_BASELINE_PATH = _REPOSITORY_ROOT / ".github" / "workflows" / "build-baseline.yml" + + +def _load_guard() -> ModuleType: + """Load the repository-owned release identity guard from its executable path.""" + assert _GUARD_PATH.is_file(), "release preflight must own a version identity guard" + guard_module_spec = importlib.util.spec_from_file_location( + "verify_release_identity", _GUARD_PATH + ) + assert guard_module_spec is not None and guard_module_spec.loader is not None + guard_module = importlib.util.module_from_spec(guard_module_spec) + guard_module_spec.loader.exec_module(guard_module) + return guard_module + + +def _write_release_metadata(repository_root: Path, release_version: str) -> None: + """Write the minimum release metadata consumed by the identity guard.""" + (repository_root / "apps" / "desktop" / "src-tauri").mkdir(parents=True) + (repository_root / "VERSION").write_text( + f"{release_version}\n", encoding="utf-8" + ) + (repository_root / "package.json").write_text( + json.dumps({"name": "bandscope", "version": release_version}), + encoding="utf-8", + ) + ( + repository_root / "apps" / "desktop" / "src-tauri" / "tauri.conf.json" + ).write_text( + json.dumps( + { + "productName": "BandScope", + "version": release_version, + "identifier": "com.bandscope.desktop", + } + ), + encoding="utf-8", + ) + + +def _workflow_job_block(workflow_text: str, job_name: str) -> str: + """Return one top-level GitHub Actions job without requiring a YAML runtime dependency.""" + job_marker = f" {job_name}:" + workflow_lines = workflow_text.splitlines() + try: + job_start_index = workflow_lines.index(job_marker) + except ValueError as lookup_error: + raise AssertionError(f"workflow job is missing: {job_name}") from lookup_error + + job_end_index = len(workflow_lines) + for line_index in range(job_start_index + 1, len(workflow_lines)): + workflow_line = workflow_lines[line_index] + if ( + workflow_line.startswith(" ") + and not workflow_line.startswith(" ") + and workflow_line.endswith(":") + ): + job_end_index = line_index + break + return "\n".join(workflow_lines[job_start_index:job_end_index]) + + +def test_release_preflight_executes_version_identity_guard() -> None: + """Keep release preflight fail-closed when version projections drift.""" + quickcheck_text = ( + _REPOSITORY_ROOT / "scripts" / "harness" / "quickcheck.sh" + ).read_text(encoding="utf-8") + release_workflow_text = ( + _REPOSITORY_ROOT / ".github" / "workflows" / "release.yml" + ).read_text(encoding="utf-8") + + assert "python3 scripts/checks/verify_release_identity.py" in quickcheck_text + assert "./scripts/harness/quickcheck.sh" in release_workflow_text + + +def test_tag_build_and_publication_depend_on_release_identity_gate() -> None: + """Block package construction and publication when release identity is invalid.""" + build_workflow_text = _BUILD_BASELINE_PATH.read_text(encoding="utf-8") + + identity_job = _workflow_job_block(build_workflow_text, "release-identity") + assert "run: python3 scripts/checks/verify_release_identity.py" in identity_job + + for build_job_name in ( + "build-windows-native", + "build-windows-arm64", + "build-macos-native", + "build-macos-arm64", + ): + build_job = _workflow_job_block(build_workflow_text, build_job_name) + assert "needs: release-identity" in build_job + + publication_job = _workflow_job_block( + build_workflow_text, "publish-immutable-release" + ) + for required_job_name in ("release-identity", "gate-windows", "gate-macos"): + assert f" - {required_job_name}" in publication_job + + +def test_repository_release_version_matches_authoritative_version_file() -> None: + """Verify checked-in projections without creating another version authority.""" + release_guard = _load_guard() + version_text = (_REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8") + assert version_text.endswith("\n") + expected_version = version_text.removesuffix("\n") + assert "\n" not in expected_version + assert ( + release_guard.verify_release_identity(_REPOSITORY_ROOT) == expected_version + ) + + +def test_release_identity_guard_rejects_metadata_drift(tmp_path: Path) -> None: + """Reject a package projection that diverges from the authoritative version.""" + release_guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") + package_document = json.loads( + (tmp_path / "package.json").read_text(encoding="utf-8") + ) + package_document["version"] = "1.2.4" + (tmp_path / "package.json").write_text( + json.dumps(package_document), encoding="utf-8" + ) + + with pytest.raises(ValueError, match="package.json version does not match VERSION"): + release_guard.verify_release_identity(tmp_path) + + +def test_release_identity_guard_rejects_wrong_tag(tmp_path: Path) -> None: + """Reject a version tag that does not identify the exact VERSION release.""" + release_guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") + + with pytest.raises(ValueError, match="release tag does not match VERSION"): + release_guard.verify_release_identity(tmp_path, release_tag="v1.2.2") + + +def test_release_identity_guard_rejects_multiline_version_authority(tmp_path: Path) -> None: + """Reject an ambiguous VERSION file even if projections repeat the same text.""" + release_guard = _load_guard() + _write_release_metadata(tmp_path, "1.2.3") + ambiguous_version = "1.2.3\n2.0.0" + (tmp_path / "VERSION").write_text( + f"{ambiguous_version}\n", encoding="utf-8" + ) + (tmp_path / "package.json").write_text( + json.dumps({"name": "bandscope", "version": ambiguous_version}), + encoding="utf-8", + ) + (tmp_path / "apps" / "desktop" / "src-tauri" / "tauri.conf.json").write_text( + json.dumps( + { + "productName": "BandScope", + "version": ambiguous_version, + "identifier": "com.bandscope.desktop", + } + ), + encoding="utf-8", + ) + + with pytest.raises( + ValueError, match="VERSION must contain exactly one non-empty version line" + ): + release_guard.verify_release_identity(tmp_path)