From 25dd87d7e0d9525823dd4ee66e983db006ffb7a9 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 00:34:23 +0200 Subject: [PATCH 1/4] ci: publish verified desktop release manifest --- .github/workflows/native-release.yml | 9 +++ RELEASES.md | 8 ++- scripts/native_release.py | 103 ++++++++++++++++++++++++++- tests/test_native_release.py | 43 +++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 3b909a5..03a1fc9 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -520,6 +520,15 @@ jobs: python scripts/native_release.py validate-sbom --file release-assets/OpenAdapt-Desktop-${{ github.ref_name }}.cyclonedx.json + - name: Generate and validate website-readable release manifest + run: | + python scripts/native_release.py website-manifest \ + --directory release-assets \ + --tag "${{ github.ref_name }}" \ + --sbom release-assets/OpenAdapt-Desktop-${{ github.ref_name }}.cyclonedx.json + python scripts/native_release.py validate-website-manifest \ + --file release-assets/openadapt-desktop-release-manifest.json + - name: Generate and verify final checksums run: | python scripts/native_release.py checksums \ diff --git a/RELEASES.md b/RELEASES.md index 3724029..7fba057 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -9,7 +9,7 @@ download, and how the lanes converge. | Lane | Tag | Trigger | Marked as | Assets | | --- | --- | --- | --- | --- | | Engine (Python package) | `vX.Y.Z` | Explicit `Release and PyPI Publish` dispatch from green protected `main` | Regular release ("Latest") | Wheel, sdist, PyPI publish attestations, **and a mirrored copy of the matching `desktop-vX.Y.Z` installer set** | -| Native installers | `desktop-vX.Y.Z` | `desktop-v*` tag push (automated, see below) | Draft, then published **prerelease** | Beta DMG (macOS arm64/x86_64), MSI + NSIS (Windows x86_64), DEB + AppImage (Linux x86_64), per-platform metadata JSON, and one `SHA256SUMS` manifest with GitHub artifact attestations | +| Native installers | `desktop-vX.Y.Z` | `desktop-v*` tag push (automated, see below) | Draft, then published **prerelease** | Beta DMG (macOS arm64/x86_64), MSI + NSIS (Windows x86_64), DEB + AppImage (Linux x86_64), per-platform metadata JSON, a website-readable release manifest, and one `SHA256SUMS` manifest with GitHub artifact attestations | The engine lane stays non-prerelease so GitHub's "Latest" pointer always names the canonical engine release. The native lane stays prerelease because its @@ -144,6 +144,12 @@ alone: `` marker. Selection logic must keep matching on the marker plus the `desktop-v` prefix, so the mirror is invisible to it. The mirror exists for humans who land on `/releases/latest`. +- `openadapt-desktop-release-manifest.json` is the website-readable index. It + lists each artifact name, platform, architecture, signing state, and SHA-256, + plus the checked CycloneDX SBOM. The release workflow validates it before + checksumming and attesting it. A consumer must still verify `SHA256SUMS` and + the GitHub attestation; the manifest does not claim that unsigned artifacts + are signed. ## Convergence plan (post-signing) diff --git a/scripts/native_release.py b/scripts/native_release.py index da2c7ad..5872463 100644 --- a/scripts/native_release.py +++ b/scripts/native_release.py @@ -45,6 +45,7 @@ ("windows", "x86_64"), ("linux", "x86_64"), } +WEBSITE_RELEASE_MANIFEST = "openadapt-desktop-release-manifest.json" def native_versions(root: Path = ROOT) -> dict[str, str]: @@ -398,7 +399,13 @@ def validate_release_set(directory: Path) -> int: actual_assets = { path.name for path in files - if not path.name.endswith("-metadata.json") and path.name != "SHA256SUMS" + if not path.name.endswith("-metadata.json") + and path.name != "SHA256SUMS" + # These release-wide integrity documents are generated after the + # per-platform staging metadata. They describe the installer set but + # are not installers themselves. + and not path.name.endswith(".cyclonedx.json") + and path.name != WEBSITE_RELEASE_MANIFEST } if actual_assets != referenced_assets: raise ValueError( @@ -437,6 +444,86 @@ def validate_sbom(path: Path) -> int: return len(components) +def write_website_release_manifest( + directory: Path, *, tag: str, sbom: Path, root: Path = ROOT +) -> Path: + """Write the verified, public release index consumed by download pages. + + This is deliberately a description of the exact staged bytes, not a claim + that an unsigned artifact is signed. ``SHA256SUMS`` subsequently binds the + manifest itself into the GitHub provenance attestation. + """ + + validate_tag(tag, root) + validate_release_set(directory) + validate_sbom(sbom) + if sbom.parent.resolve() != directory.resolve(): + raise ValueError("SBOM must be inside the release asset directory") + output = directory / WEBSITE_RELEASE_MANIFEST + if output.exists(): + raise ValueError(f"release manifest already exists: {output}") + + assets: list[dict[str, str]] = [] + for metadata_path in sorted(directory.glob("*-metadata.json")): + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + for name in metadata["artifacts"]: + path = directory / name + assets.append( + { + "name": name, + "platform": metadata["platform"], + "architecture": metadata["architecture"], + "signing": metadata["signing"], + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + ) + assets.sort(key=lambda item: item["name"]) + payload = { + "schema_version": 1, + "lifecycle": LIFECYCLE, + "native_tag": tag, + "native_version": native_version(root), + "source_commit": os.environ.get("GITHUB_SHA", "local"), + "verification": { + "sha256_manifest": "SHA256SUMS", + "github_artifact_attestation": "required", + "installer_smoke": "install, launch, and uninstall", + }, + "sbom": { + "name": sbom.name, + "sha256": hashlib.sha256(sbom.read_bytes()).hexdigest(), + "format": "CycloneDX", + }, + "artifacts": assets, + } + output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return output + + +def validate_website_release_manifest(path: Path, *, root: Path = ROOT) -> int: + """Refuse a malformed manifest or a signing claim not present in its files.""" + + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema_version") != 1 or data.get("lifecycle") != LIFECYCLE: + raise ValueError(f"invalid website release manifest: {path}") + validate_tag(str(data.get("native_tag", "")), root) + if data.get("native_version") != native_version(root): + raise ValueError(f"wrong native version in website release manifest: {path}") + assets = data.get("artifacts") + if not isinstance(assets, list) or len(assets) != 6: + raise ValueError(f"website release manifest has an incomplete artifact set: {path}") + for asset in assets: + if not isinstance(asset, dict) or asset.get("signing") not in SIGNING_MODES.get( + asset.get("platform"), set() + ): + raise ValueError(f"website release manifest has invalid signing metadata: {path}") + name = asset.get("name") + digest = asset.get("sha256") + if not isinstance(name, str) or not isinstance(digest, str) or len(digest) != 64: + raise ValueError(f"website release manifest has invalid artifact digest: {path}") + return len(assets) + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="command", required=True) @@ -481,6 +568,12 @@ def _parser() -> argparse.ArgumentParser: validate_set_parser.add_argument("--directory", type=Path, required=True) validate_sbom_parser = subparsers.add_parser("validate-sbom") validate_sbom_parser.add_argument("--file", type=Path, required=True) + website_manifest_parser = subparsers.add_parser("website-manifest") + website_manifest_parser.add_argument("--directory", type=Path, required=True) + website_manifest_parser.add_argument("--tag", required=True) + website_manifest_parser.add_argument("--sbom", type=Path, required=True) + validate_website_manifest_parser = subparsers.add_parser("validate-website-manifest") + validate_website_manifest_parser.add_argument("--file", type=Path, required=True) return parser @@ -545,6 +638,14 @@ def main() -> int: elif args.command == "validate-sbom": count = validate_sbom(args.file) print(f"Validated {count} components in {args.file}") + elif args.command == "website-manifest": + path = write_website_release_manifest( + args.directory, tag=args.tag, sbom=args.sbom + ) + print(path) + elif args.command == "validate-website-manifest": + count = validate_website_release_manifest(args.file) + print(f"Validated {count} website release artifacts in {args.file}") except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 2 diff --git a/tests/test_native_release.py b/tests/test_native_release.py index 8813889..e4712a5 100644 --- a/tests/test_native_release.py +++ b/tests/test_native_release.py @@ -18,8 +18,10 @@ validate_release_set, validate_sbom, validate_tag, + validate_website_release_manifest, verify_checksums, write_checksums, + write_website_release_manifest, ) ROOT = Path(__file__).resolve().parents[1] @@ -62,6 +64,8 @@ def test_native_workflows_are_pinned_and_preserve_beta_boundary() -> None: assert "upload-artifact: false" in release assert "upload-release-assets: false" in release assert "native_release.py validate-sbom" in release + assert "native_release.py website-manifest" in release + assert "native_release.py validate-website-manifest" in release assert release.index("anchore/sbom-action@") < release.index( "native_release.py validate-sbom" ) < release.index("- name: Generate and verify final checksums") @@ -665,3 +669,42 @@ def test_validate_release_set_requires_every_platform_and_no_extra_files(tmp_pat (release / "unexpected.bin").write_bytes(b"unexpected") with pytest.raises(ValueError, match="release assets differ"): validate_release_set(release) + + +def test_website_release_manifest_is_an_honest_index_of_staged_bytes(tmp_path: Path) -> None: + specifications = [ + ("macos", "arm64", "adhoc", ["dmg/app-arm.dmg"]), + ("macos", "x86_64", "adhoc", ["dmg/app-intel.dmg"]), + ("windows", "x86_64", "unsigned", ["msi/app.msi", "nsis/app-setup.exe"]), + ("linux", "x86_64", "unsigned", ["deb/app.deb", "appimage/app.AppImage"]), + ] + release = tmp_path / "release" + release.mkdir() + for index, (platform, architecture, signing, files) in enumerate(specifications): + bundle = tmp_path / f"bundle-{index}" + for relative in files: + path = bundle / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(relative.encode()) + for path in stage_artifacts( + bundle_root=bundle, + output=tmp_path / f"stage-{index}", + platform=platform, + architecture=architecture, + signing=signing, + ): + path.rename(release / path.name) + sbom = release / "release.cyclonedx.json" + sbom.write_text( + json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, + "metadata": {"tools": [{"name": "Syft"}]}, + "components": [{"name": "openadapt-flow"}]}), + encoding="utf-8", + ) + output = write_website_release_manifest( + release, tag=f"desktop-v{native_version()}", sbom=sbom + ) + assert validate_website_release_manifest(output) == 6 + manifest = json.loads(output.read_text()) + assert {asset["signing"] for asset in manifest["artifacts"]} == {"adhoc", "unsigned"} + assert manifest["verification"]["github_artifact_attestation"] == "required" From 256eb3c7bd0d31ab8a6914085e0e900dc33653f6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 01:04:28 +0200 Subject: [PATCH 2/4] fix: verify desktop release manifest bytes --- .github/workflows/native-release.yml | 20 +++-- RELEASES.md | 9 ++- scripts/native_release.py | 113 ++++++++++++++++++++++++--- tests/test_native_release.py | 87 +++++++++++++++++++-- 4 files changed, 201 insertions(+), 28 deletions(-) diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index 03a1fc9..1fd415e 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -520,14 +520,12 @@ jobs: python scripts/native_release.py validate-sbom --file release-assets/OpenAdapt-Desktop-${{ github.ref_name }}.cyclonedx.json - - name: Generate and validate website-readable release manifest - run: | - python scripts/native_release.py website-manifest \ - --directory release-assets \ - --tag "${{ github.ref_name }}" \ - --sbom release-assets/OpenAdapt-Desktop-${{ github.ref_name }}.cyclonedx.json - python scripts/native_release.py validate-website-manifest \ - --file release-assets/openadapt-desktop-release-manifest.json + - name: Generate website-readable release manifest + run: >- + python scripts/native_release.py website-manifest + --directory release-assets + --tag "${{ github.ref_name }}" + --sbom release-assets/OpenAdapt-Desktop-${{ github.ref_name }}.cyclonedx.json - name: Generate and verify final checksums run: | @@ -538,6 +536,12 @@ jobs: --directory release-assets \ --manifest release-assets/SHA256SUMS + - name: Validate website manifest against exact checksummed bytes + run: >- + python scripts/native_release.py validate-website-manifest + --file release-assets/openadapt-desktop-release-manifest.json + --checksums release-assets/SHA256SUMS + - name: Attest the exact files named by SHA256SUMS uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4 with: diff --git a/RELEASES.md b/RELEASES.md index 7fba057..ec8320b 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -146,10 +146,11 @@ alone: mirror exists for humans who land on `/releases/latest`. - `openadapt-desktop-release-manifest.json` is the website-readable index. It lists each artifact name, platform, architecture, signing state, and SHA-256, - plus the checked CycloneDX SBOM. The release workflow validates it before - checksumming and attesting it. A consumer must still verify `SHA256SUMS` and - the GitHub attestation; the manifest does not claim that unsigned artifacts - are signed. + plus the checked CycloneDX SBOM. The release workflow recomputes the named + hashes and validates the complete file set against `SHA256SUMS` before it + creates the GitHub attestation. A consumer must still verify the checksums + and attestation; the manifest does not claim that unsigned artifacts are + signed. ## Convergence plan (post-signing) diff --git a/scripts/native_release.py b/scripts/native_release.py index 5872463..08ba21b 100644 --- a/scripts/native_release.py +++ b/scripts/native_release.py @@ -327,13 +327,9 @@ def write_checksums(directory: Path, output: Path) -> list[tuple[str, str]]: def verify_checksums(directory: Path, manifest: Path) -> int: + entries = read_checksums(manifest) checked = 0 - for line in manifest.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - digest, separator, name = line.partition(" ") - if not separator or len(digest) != 64 or Path(name).name != name: - raise ValueError(f"invalid checksum line: {line!r}") + for name, digest in entries.items(): path = directory / name if not path.is_file(): raise ValueError(f"checksum target is missing: {path}") @@ -346,6 +342,29 @@ def verify_checksums(directory: Path, manifest: Path) -> int: return checked +def read_checksums(manifest: Path) -> dict[str, str]: + """Read a strict SHA256SUMS file and reject duplicate asset names.""" + + entries: dict[str, str] = {} + for line in manifest.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + digest, separator, name = line.partition(" ") + if ( + not separator + or not re.fullmatch(r"[0-9a-f]{64}", digest) + or Path(name).name != name + or not name + ): + raise ValueError(f"invalid checksum line: {line!r}") + if name in entries: + raise ValueError(f"duplicate checksum target: {name}") + entries[name] = digest + if not entries: + raise ValueError("checksum manifest is empty") + return entries + + def validate_release_set(directory: Path) -> int: files = sorted(path for path in directory.iterdir() if path.is_file()) metadata_paths = [path for path in files if path.name.endswith("-metadata.json")] @@ -500,8 +519,10 @@ def write_website_release_manifest( return output -def validate_website_release_manifest(path: Path, *, root: Path = ROOT) -> int: - """Refuse a malformed manifest or a signing claim not present in its files.""" +def validate_website_release_manifest( + path: Path, *, checksums: Path, root: Path = ROOT +) -> int: + """Validate the public index against metadata, bytes, SBOM, and checksums.""" data = json.loads(path.read_text(encoding="utf-8")) if data.get("schema_version") != 1 or data.get("lifecycle") != LIFECYCLE: @@ -509,9 +530,28 @@ def validate_website_release_manifest(path: Path, *, root: Path = ROOT) -> int: validate_tag(str(data.get("native_tag", "")), root) if data.get("native_version") != native_version(root): raise ValueError(f"wrong native version in website release manifest: {path}") + directory = path.parent + if path.name != WEBSITE_RELEASE_MANIFEST or checksums.parent.resolve() != directory.resolve(): + raise ValueError("website manifest and SHA256SUMS must be in the release directory") + validate_release_set(directory) + metadata_by_asset: dict[str, tuple[str, str, str]] = {} + for metadata_path in sorted(directory.glob("*-metadata.json")): + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + for name in metadata.get("artifacts", []): + if name in metadata_by_asset: + raise ValueError(f"duplicate artifact in platform metadata: {name}") + metadata_by_asset[name] = ( + metadata.get("platform"), + metadata.get("architecture"), + metadata.get("signing"), + ) + # Each release has one signing mode per platform. Platform metadata is the + # source of truth for which permitted mode the build selected. + expected_names = set(metadata_by_asset) assets = data.get("artifacts") - if not isinstance(assets, list) or len(assets) != 6: + if not isinstance(assets, list) or len(assets) != len(expected_names): raise ValueError(f"website release manifest has an incomplete artifact set: {path}") + observed_names: set[str] = set() for asset in assets: if not isinstance(asset, dict) or asset.get("signing") not in SIGNING_MODES.get( asset.get("platform"), set() @@ -519,8 +559,58 @@ def validate_website_release_manifest(path: Path, *, root: Path = ROOT) -> int: raise ValueError(f"website release manifest has invalid signing metadata: {path}") name = asset.get("name") digest = asset.get("sha256") - if not isinstance(name, str) or not isinstance(digest, str) or len(digest) != 64: + if ( + not isinstance(name, str) + or name in observed_names + or name not in expected_names + or not isinstance(digest, str) + or not re.fullmatch(r"[0-9a-f]{64}", digest) + ): raise ValueError(f"website release manifest has invalid artifact digest: {path}") + observed_names.add(name) + platform, architecture, signing = metadata_by_asset[name] + if (asset.get("platform"), asset.get("architecture"), asset.get("signing")) != ( + platform, + architecture, + signing, + ): + raise ValueError(f"website release manifest disagrees with platform metadata: {name}") + artifact_path = directory / name + if not artifact_path.is_file(): + raise ValueError(f"website release manifest references missing artifact: {name}") + actual_digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest() + if digest != actual_digest: + raise ValueError(f"website release manifest digest differs for {name}") + if observed_names != expected_names: + raise ValueError("website release manifest artifact names are incomplete") + + sbom = data.get("sbom") + expected_sbom_name = f"OpenAdapt-Desktop-{data['native_tag']}.cyclonedx.json" + if not isinstance(sbom, dict) or sbom.get("name") != expected_sbom_name: + raise ValueError("website release manifest names the wrong SBOM") + sbom_path = directory / expected_sbom_name + if not sbom_path.is_file(): + raise ValueError(f"website release manifest references missing SBOM: {sbom_path}") + validate_sbom(sbom_path) + sbom_digest = hashlib.sha256(sbom_path.read_bytes()).hexdigest() + if sbom.get("sha256") != sbom_digest: + raise ValueError("website release manifest SBOM digest differs") + + checksum_entries = read_checksums(checksums) + expected_checksum_names = ( + expected_names + | {metadata.name for metadata in directory.glob("*-metadata.json")} + | {expected_sbom_name, WEBSITE_RELEASE_MANIFEST} + ) + if set(checksum_entries) != expected_checksum_names: + raise ValueError("SHA256SUMS does not describe the exact release file set") + for name in expected_names | {expected_sbom_name, WEBSITE_RELEASE_MANIFEST}: + actual_digest = hashlib.sha256((directory / name).read_bytes()).hexdigest() + if checksum_entries.get(name) != actual_digest: + raise ValueError(f"SHA256SUMS digest differs for {name}") + expected_commit = os.environ.get("GITHUB_SHA") + if expected_commit and data.get("source_commit") != expected_commit: + raise ValueError("website release manifest source commit differs") return len(assets) @@ -574,6 +664,7 @@ def _parser() -> argparse.ArgumentParser: website_manifest_parser.add_argument("--sbom", type=Path, required=True) validate_website_manifest_parser = subparsers.add_parser("validate-website-manifest") validate_website_manifest_parser.add_argument("--file", type=Path, required=True) + validate_website_manifest_parser.add_argument("--checksums", type=Path, required=True) return parser @@ -644,7 +735,7 @@ def main() -> int: ) print(path) elif args.command == "validate-website-manifest": - count = validate_website_release_manifest(args.file) + count = validate_website_release_manifest(args.file, checksums=args.checksums) print(f"Validated {count} website release artifacts in {args.file}") except (OSError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) diff --git a/tests/test_native_release.py b/tests/test_native_release.py index e4712a5..f6579e5 100644 --- a/tests/test_native_release.py +++ b/tests/test_native_release.py @@ -671,7 +671,7 @@ def test_validate_release_set_requires_every_platform_and_no_extra_files(tmp_pat validate_release_set(release) -def test_website_release_manifest_is_an_honest_index_of_staged_bytes(tmp_path: Path) -> None: +def _stage_complete_release(tmp_path: Path) -> tuple[Path, Path, Path]: specifications = [ ("macos", "arm64", "adhoc", ["dmg/app-arm.dmg"]), ("macos", "x86_64", "adhoc", ["dmg/app-intel.dmg"]), @@ -679,7 +679,7 @@ def test_website_release_manifest_is_an_honest_index_of_staged_bytes(tmp_path: P ("linux", "x86_64", "unsigned", ["deb/app.deb", "appimage/app.AppImage"]), ] release = tmp_path / "release" - release.mkdir() + release.mkdir(parents=True) for index, (platform, architecture, signing, files) in enumerate(specifications): bundle = tmp_path / f"bundle-{index}" for relative in files: @@ -694,7 +694,8 @@ def test_website_release_manifest_is_an_honest_index_of_staged_bytes(tmp_path: P signing=signing, ): path.rename(release / path.name) - sbom = release / "release.cyclonedx.json" + tag = f"desktop-v{native_version()}" + sbom = release / f"OpenAdapt-Desktop-{tag}.cyclonedx.json" sbom.write_text( json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, "metadata": {"tools": [{"name": "Syft"}]}, @@ -702,9 +703,85 @@ def test_website_release_manifest_is_an_honest_index_of_staged_bytes(tmp_path: P encoding="utf-8", ) output = write_website_release_manifest( - release, tag=f"desktop-v{native_version()}", sbom=sbom + release, tag=tag, sbom=sbom ) - assert validate_website_release_manifest(output) == 6 + checksums = release / "SHA256SUMS" + write_checksums(release, checksums) + return release, output, checksums + + +def test_website_release_manifest_is_an_honest_index_of_staged_bytes(tmp_path: Path) -> None: + _, output, checksums = _stage_complete_release(tmp_path) + assert validate_website_release_manifest(output, checksums=checksums) == 6 manifest = json.loads(output.read_text()) assert {asset["signing"] for asset in manifest["artifacts"]} == {"adhoc", "unsigned"} assert manifest["verification"]["github_artifact_attestation"] == "required" + + +def test_website_release_manifest_rejects_duplicate_and_nonexistent_assets( + tmp_path: Path, +) -> None: + _, output, checksums = _stage_complete_release(tmp_path) + manifest = json.loads(output.read_text()) + manifest["artifacts"][1] = dict(manifest["artifacts"][0]) + output.write_text(json.dumps(manifest), encoding="utf-8") + write_checksums(output.parent, checksums) + with pytest.raises(ValueError, match="invalid artifact digest|incomplete"): + validate_website_release_manifest(output, checksums=checksums) + + _, output, checksums = _stage_complete_release(tmp_path / "nonexistent") + manifest = json.loads(output.read_text()) + manifest["artifacts"][0]["name"] = "OpenAdapt-Desktop-does-not-exist.dmg" + output.write_text(json.dumps(manifest), encoding="utf-8") + write_checksums(output.parent, checksums) + with pytest.raises(ValueError, match="invalid artifact digest"): + validate_website_release_manifest(output, checksums=checksums) + + +def test_website_release_manifest_rejects_hash_metadata_and_byte_tampering( + tmp_path: Path, +) -> None: + _, output, checksums = _stage_complete_release(tmp_path) + manifest = json.loads(output.read_text()) + manifest["artifacts"][0]["sha256"] = "0" * 64 + output.write_text(json.dumps(manifest), encoding="utf-8") + write_checksums(output.parent, checksums) + with pytest.raises(ValueError, match="digest differs"): + validate_website_release_manifest(output, checksums=checksums) + + _, output, checksums = _stage_complete_release(tmp_path / "metadata") + manifest = json.loads(output.read_text()) + manifest["artifacts"][0]["architecture"] = "mips64" + output.write_text(json.dumps(manifest), encoding="utf-8") + write_checksums(output.parent, checksums) + with pytest.raises(ValueError, match="platform metadata"): + validate_website_release_manifest(output, checksums=checksums) + + release, output, checksums = _stage_complete_release(tmp_path / "bytes") + manifest = json.loads(output.read_text()) + (release / manifest["artifacts"][0]["name"]).write_bytes(b"tampered") + write_checksums(release, checksums) + with pytest.raises(ValueError, match="digest differs"): + validate_website_release_manifest(output, checksums=checksums) + + +def test_website_release_manifest_rejects_sbom_and_checksum_tampering(tmp_path: Path) -> None: + release, output, checksums = _stage_complete_release(tmp_path) + manifest = json.loads(output.read_text()) + manifest["sbom"]["sha256"] = "f" * 64 + output.write_text(json.dumps(manifest), encoding="utf-8") + write_checksums(release, checksums) + with pytest.raises(ValueError, match="SBOM digest"): + validate_website_release_manifest(output, checksums=checksums) + + release, output, checksums = _stage_complete_release(tmp_path / "checksum") + lines = checksums.read_text().splitlines() + checksums.write_text("\n".join(lines[:-1]) + "\n", encoding="utf-8") + with pytest.raises(ValueError, match="exact release file set"): + validate_website_release_manifest(output, checksums=checksums) + + release, output, checksums = _stage_complete_release(tmp_path / "missing-sbom") + manifest = json.loads(output.read_text()) + (release / manifest["sbom"]["name"]).unlink() + with pytest.raises(ValueError, match="missing SBOM"): + validate_website_release_manifest(output, checksums=checksums) From 76bdf3a57e8728e97ed78463d1159916fa647b57 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 01:43:39 +0200 Subject: [PATCH 3/4] fix: validate release verification contract --- scripts/native_release.py | 38 +++++++++--------- tests/test_native_release.py | 74 +++++++++++++++++++++++++----------- 2 files changed, 72 insertions(+), 40 deletions(-) diff --git a/scripts/native_release.py b/scripts/native_release.py index 08ba21b..3720538 100644 --- a/scripts/native_release.py +++ b/scripts/native_release.py @@ -46,6 +46,12 @@ ("linux", "x86_64"), } WEBSITE_RELEASE_MANIFEST = "openadapt-desktop-release-manifest.json" +WEBSITE_RELEASE_VERIFICATION = { + "sha256_manifest": "SHA256SUMS", + "github_artifact_attestation": "required", + "installer_smoke": "install, launch, and uninstall", +} +WEBSITE_RELEASE_SBOM_FORMAT = "CycloneDX" def native_versions(root: Path = ROOT) -> dict[str, str]: @@ -92,9 +98,7 @@ def set_native_version(version: str, root: Path = ROOT) -> dict[str, str]: def rewrite_json(path: Path, mutate) -> None: data = json.loads(path.read_text(encoding="utf-8")) mutate(data) - path.write_text( - json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" - ) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def set_lock_versions(lock: dict) -> None: lock["version"] = version @@ -206,7 +210,7 @@ def installer_pointer_notes(body: str, native_tag: str, repo: str) -> str | None "> macOS DMG (arm64 and x86_64), Windows MSI and NSIS `.exe`, Linux\n" "> `.deb` and `.AppImage`, plus `SHA256SUMS` — the same attested bytes\n" f"> published at [`{native_tag}`]({base}/tag/{native_tag}), mirrored\n" - "> here so GitHub's \"Latest\" always carries an installer.\n" + '> here so GitHub\'s "Latest" always carries an installer.\n' ">\n" "> **These installers are Beta and are ad-hoc-signed (macOS) or\n" "> unsigned (Windows, Linux)** pending signing credentials; the signing\n" @@ -503,15 +507,11 @@ def write_website_release_manifest( "native_tag": tag, "native_version": native_version(root), "source_commit": os.environ.get("GITHUB_SHA", "local"), - "verification": { - "sha256_manifest": "SHA256SUMS", - "github_artifact_attestation": "required", - "installer_smoke": "install, launch, and uninstall", - }, + "verification": WEBSITE_RELEASE_VERIFICATION, "sbom": { "name": sbom.name, "sha256": hashlib.sha256(sbom.read_bytes()).hexdigest(), - "format": "CycloneDX", + "format": WEBSITE_RELEASE_SBOM_FORMAT, }, "artifacts": assets, } @@ -519,9 +519,7 @@ def write_website_release_manifest( return output -def validate_website_release_manifest( - path: Path, *, checksums: Path, root: Path = ROOT -) -> int: +def validate_website_release_manifest(path: Path, *, checksums: Path, root: Path = ROOT) -> int: """Validate the public index against metadata, bytes, SBOM, and checksums.""" data = json.loads(path.read_text(encoding="utf-8")) @@ -530,6 +528,8 @@ def validate_website_release_manifest( validate_tag(str(data.get("native_tag", "")), root) if data.get("native_version") != native_version(root): raise ValueError(f"wrong native version in website release manifest: {path}") + if data.get("verification") != WEBSITE_RELEASE_VERIFICATION: + raise ValueError("website release manifest has an invalid verification contract") directory = path.parent if path.name != WEBSITE_RELEASE_MANIFEST or checksums.parent.resolve() != directory.resolve(): raise ValueError("website manifest and SHA256SUMS must be in the release directory") @@ -586,8 +586,14 @@ def validate_website_release_manifest( sbom = data.get("sbom") expected_sbom_name = f"OpenAdapt-Desktop-{data['native_tag']}.cyclonedx.json" - if not isinstance(sbom, dict) or sbom.get("name") != expected_sbom_name: + if ( + not isinstance(sbom, dict) + or set(sbom) != {"name", "sha256", "format"} + or sbom.get("name") != expected_sbom_name + ): raise ValueError("website release manifest names the wrong SBOM") + if sbom.get("format") != WEBSITE_RELEASE_SBOM_FORMAT: + raise ValueError("website release manifest has an invalid SBOM format") sbom_path = directory / expected_sbom_name if not sbom_path.is_file(): raise ValueError(f"website release manifest references missing SBOM: {sbom_path}") @@ -730,9 +736,7 @@ def main() -> int: count = validate_sbom(args.file) print(f"Validated {count} components in {args.file}") elif args.command == "website-manifest": - path = write_website_release_manifest( - args.directory, tag=args.tag, sbom=args.sbom - ) + path = write_website_release_manifest(args.directory, tag=args.tag, sbom=args.sbom) print(path) elif args.command == "validate-website-manifest": count = validate_website_release_manifest(args.file, checksums=args.checksums) diff --git a/tests/test_native_release.py b/tests/test_native_release.py index f6579e5..a17e195 100644 --- a/tests/test_native_release.py +++ b/tests/test_native_release.py @@ -66,9 +66,11 @@ def test_native_workflows_are_pinned_and_preserve_beta_boundary() -> None: assert "native_release.py validate-sbom" in release assert "native_release.py website-manifest" in release assert "native_release.py validate-website-manifest" in release - assert release.index("anchore/sbom-action@") < release.index( - "native_release.py validate-sbom" - ) < release.index("- name: Generate and verify final checksums") + assert ( + release.index("anchore/sbom-action@") + < release.index("native_release.py validate-sbom") + < release.index("- name: Generate and verify final checksums") + ) assert "attestations: write" in release assert "id-token: write" in release assert "contents: write" in release @@ -111,9 +113,7 @@ def test_validate_sbom_requires_cyclonedx_generator_and_named_components( "bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, - "metadata": { - "tools": [{"vendor": "Anchore", "name": "Syft", "version": "1.44.0"}] - }, + "metadata": {"tools": [{"vendor": "Anchore", "name": "Syft", "version": "1.44.0"}]}, "components": [ { "type": "library", @@ -171,8 +171,7 @@ def test_security_workflows_cover_all_languages_and_pin_every_dependency() -> No assert "GITLEAKS_VERSION: 8.30.1" in secret_scan assert ( "GITLEAKS_SHA256: " - "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" - in secret_scan + "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" in secret_scan ) assert "sha256sum --check --strict" in secret_scan @@ -428,9 +427,7 @@ def test_superseded_notes_is_idempotent_and_upgrades_to_newer_pointer() -> None: def test_installer_pointer_prepends_block_and_preserves_engine_notes() -> None: body = "## v0.5.0 (2026-07-26)\n\n### Bug Fixes\n\n- Something.\n" - updated = installer_pointer_notes( - body, "desktop-v0.5.0", "OpenAdaptAI/openadapt-desktop" - ) + updated = installer_pointer_notes(body, "desktop-v0.5.0", "OpenAdaptAI/openadapt-desktop") assert updated is not None assert updated.startswith("\n") @@ -458,9 +455,7 @@ def test_installer_pointer_is_idempotent_and_retargets_a_newer_native_tag() -> N # Re-running the same publish must not append a second block. assert installer_pointer_notes(once, "desktop-v0.5.0", "OpenAdaptAI/openadapt-desktop") is None - retargeted = installer_pointer_notes( - once, "desktop-v0.6.0", "OpenAdaptAI/openadapt-desktop" - ) + retargeted = installer_pointer_notes(once, "desktop-v0.6.0", "OpenAdaptAI/openadapt-desktop") assert retargeted is not None assert retargeted.count("openadapt-installer-pointer:start") == 1 assert retargeted.count("openadapt-installer-pointer:end") == 1 @@ -474,9 +469,7 @@ def test_installer_pointer_refuses_a_malformed_or_truncated_block() -> None: truncated = "\nhalf a block\n" with pytest.raises(ValueError): - installer_pointer_notes( - truncated, "desktop-v0.5.0", "OpenAdaptAI/openadapt-desktop" - ) + installer_pointer_notes(truncated, "desktop-v0.5.0", "OpenAdaptAI/openadapt-desktop") def test_native_release_workflow_points_latest_at_the_published_installers() -> None: @@ -697,14 +690,18 @@ def _stage_complete_release(tmp_path: Path) -> tuple[Path, Path, Path]: tag = f"desktop-v{native_version()}" sbom = release / f"OpenAdapt-Desktop-{tag}.cyclonedx.json" sbom.write_text( - json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6", "version": 1, - "metadata": {"tools": [{"name": "Syft"}]}, - "components": [{"name": "openadapt-flow"}]}), + json.dumps( + { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": {"tools": [{"name": "Syft"}]}, + "components": [{"name": "openadapt-flow"}], + } + ), encoding="utf-8", ) - output = write_website_release_manifest( - release, tag=tag, sbom=sbom - ) + output = write_website_release_manifest(release, tag=tag, sbom=sbom) checksums = release / "SHA256SUMS" write_checksums(release, checksums) return release, output, checksums @@ -718,6 +715,37 @@ def test_website_release_manifest_is_an_honest_index_of_staged_bytes(tmp_path: P assert manifest["verification"]["github_artifact_attestation"] == "required" +@pytest.mark.parametrize( + ("field", "value"), + [ + ("sha256_manifest", "checksums.txt"), + ("github_artifact_attestation", "optional"), + ("installer_smoke", "install only"), + ("unexpected", "accepted"), + ], +) +def test_website_release_manifest_rejects_modified_verification_contract( + tmp_path: Path, field: str, value: str +) -> None: + release, output, checksums = _stage_complete_release(tmp_path) + manifest = json.loads(output.read_text()) + manifest["verification"][field] = value + output.write_text(json.dumps(manifest), encoding="utf-8") + write_checksums(release, checksums) + with pytest.raises(ValueError, match="invalid verification contract"): + validate_website_release_manifest(output, checksums=checksums) + + +def test_website_release_manifest_rejects_modified_sbom_format(tmp_path: Path) -> None: + release, output, checksums = _stage_complete_release(tmp_path) + manifest = json.loads(output.read_text()) + manifest["sbom"]["format"] = "SPDX" + output.write_text(json.dumps(manifest), encoding="utf-8") + write_checksums(release, checksums) + with pytest.raises(ValueError, match="invalid SBOM format"): + validate_website_release_manifest(output, checksums=checksums) + + def test_website_release_manifest_rejects_duplicate_and_nonexistent_assets( tmp_path: Path, ) -> None: From 6357e389baee69a85e9fbf51439262de27d8dea0 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 6 Aug 2026 02:25:53 +0200 Subject: [PATCH 4/4] fix: verify release metadata checksums --- scripts/native_release.py | 14 ++++++++++---- tests/test_native_release.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/native_release.py b/scripts/native_release.py index 3720538..a4aa343 100644 --- a/scripts/native_release.py +++ b/scripts/native_release.py @@ -603,14 +603,20 @@ def validate_website_release_manifest(path: Path, *, checksums: Path, root: Path raise ValueError("website release manifest SBOM digest differs") checksum_entries = read_checksums(checksums) + metadata_names = {metadata.name for metadata in directory.glob("*-metadata.json")} expected_checksum_names = ( - expected_names - | {metadata.name for metadata in directory.glob("*-metadata.json")} - | {expected_sbom_name, WEBSITE_RELEASE_MANIFEST} + expected_names | metadata_names | {expected_sbom_name, WEBSITE_RELEASE_MANIFEST} ) if set(checksum_entries) != expected_checksum_names: raise ValueError("SHA256SUMS does not describe the exact release file set") - for name in expected_names | {expected_sbom_name, WEBSITE_RELEASE_MANIFEST}: + for name in ( + expected_names + | metadata_names + | { + expected_sbom_name, + WEBSITE_RELEASE_MANIFEST, + } + ): actual_digest = hashlib.sha256((directory / name).read_bytes()).hexdigest() if checksum_entries.get(name) != actual_digest: raise ValueError(f"SHA256SUMS digest differs for {name}") diff --git a/tests/test_native_release.py b/tests/test_native_release.py index a17e195..34485b6 100644 --- a/tests/test_native_release.py +++ b/tests/test_native_release.py @@ -813,3 +813,18 @@ def test_website_release_manifest_rejects_sbom_and_checksum_tampering(tmp_path: (release / manifest["sbom"]["name"]).unlink() with pytest.raises(ValueError, match="missing SBOM"): validate_website_release_manifest(output, checksums=checksums) + + +def test_website_release_manifest_rejects_forged_metadata_checksum(tmp_path: Path) -> None: + _, output, checksums = _stage_complete_release(tmp_path) + lines = checksums.read_text(encoding="utf-8").splitlines() + metadata_index = next( + index for index, line in enumerate(lines) if line.endswith("-metadata.json") + ) + _digest, separator, name = lines[metadata_index].partition(" ") + assert separator and name + lines[metadata_index] = f"{'0' * 64} {name}" + checksums.write_text("\n".join(lines) + "\n", encoding="utf-8") + + with pytest.raises(ValueError, match="SHA256SUMS digest differs"): + validate_website_release_manifest(output, checksums=checksums)