diff --git a/tests/test_content_packs_vst.py b/tests/test_content_packs_vst.py new file mode 100644 index 0000000..8204f8c --- /dev/null +++ b/tests/test_content_packs_vst.py @@ -0,0 +1,91 @@ +"""VST-pack slicing (tools/content_packs.build_vst_pack): each platform pack +keeps only its own binaries + the shared bundle files, drops the rest, and is +reproducible.""" +import zipfile +from pathlib import Path + +from tools import content_packs + + +def _fake_vst_tree(root: Path): + # One fat .vst3 with all three platform binaries + shared files, plus a + # src/ build tree that must never ship. + c = root / "amps" / "Foo.vst3" / "Contents" + (c / "MacOS").mkdir(parents=True) + (c / "x86_64-win").mkdir(parents=True) + (c / "x86_64-linux").mkdir(parents=True) + (c / "Resources").mkdir(parents=True) + (c / "MacOS" / "Foo").write_bytes(b"mac-binary") + (c / "x86_64-win" / "Foo.vst3").write_bytes(b"win-binary") + (c / "x86_64-linux" / "Foo.so").write_bytes(b"linux-binary") + (c / "Info.plist").write_bytes(b"") + (c / "Resources" / "moduleinfo.json").write_bytes(b"{}") + (root / "src" / "build").mkdir(parents=True) + (root / "src" / "build" / "junk.o").write_bytes(b"objfile") + + +def _names(zip_path): + with zipfile.ZipFile(zip_path) as zf: + return set(zf.namelist()) + + +def test_slice_keeps_target_platform_and_shared_drops_foreign(tmp_path): + root = tmp_path / "vst" + _fake_vst_tree(root) + content_packs.build_vst_pack(root, tmp_path / "mac.zip", "mac") + names = _names(tmp_path / "mac.zip") + + base = "amps/Foo.vst3/Contents" + assert f"{base}/MacOS/Foo" in names # target binary kept + assert f"{base}/Info.plist" in names # shared kept + assert f"{base}/Resources/moduleinfo.json" in names # shared kept + assert f"{base}/x86_64-win/Foo.vst3" not in names # foreign dropped + assert f"{base}/x86_64-linux/Foo.so" not in names # foreign dropped + assert not any(n.startswith("src/") for n in names) # build trees never ship + + +def test_each_platform_gets_its_own_binary(tmp_path): + root = tmp_path / "vst" + _fake_vst_tree(root) + wanted = {"mac": "MacOS/Foo", "win": "x86_64-win/Foo.vst3", "linux": "x86_64-linux/Foo.so"} + for plat, rel in wanted.items(): + content_packs.build_vst_pack(root, tmp_path / f"{plat}.zip", plat) + names = _names(tmp_path / f"{plat}.zip") + assert f"amps/Foo.vst3/Contents/{rel}" in names + others = [v for k, v in wanted.items() if k != plat] + for o in others: + assert f"amps/Foo.vst3/Contents/{o}" not in names + + +def test_slice_is_reproducible(tmp_path): + root = tmp_path / "vst" + _fake_vst_tree(root) + a = content_packs.build_vst_pack(root, tmp_path / "a.zip", "linux") + b = content_packs.build_vst_pack(root, tmp_path / "b.zip", "linux") + assert a == b and a["sha256"] + + +def test_slice_pins_create_system_for_cross_runner_reproducibility(tmp_path, monkeypatch): + # ZipInfo defaults create_system from the host OS (0 on Windows, 3 on Unix), + # and it lands in the central directory — so without an explicit pin the same + # tree hashes differently on a Windows runner, breaking the precomputable-hash + # guarantee exactly where it matters (native .vst3 are built on Windows). A + # same-machine reproducibility test can't catch that; simulate win32 and + # assert the pin forces 3 regardless. + monkeypatch.setattr(zipfile.sys, "platform", "win32") + root = tmp_path / "vst" + _fake_vst_tree(root) + content_packs.build_vst_pack(root, tmp_path / "w.zip", "linux") + with zipfile.ZipFile(tmp_path / "w.zip") as zf: + assert all(i.create_system == 3 for i in zf.infolist()) + + +def test_unknown_platform_rejected(tmp_path): + root = tmp_path / "vst" + _fake_vst_tree(root) + try: + content_packs.build_vst_pack(root, tmp_path / "x.zip", "bsd") + except ValueError as e: + assert "unknown platform" in str(e) + else: + raise AssertionError("build_vst_pack accepted an unknown platform") diff --git a/tools/content_packs.py b/tools/content_packs.py index a4e0d9e..759905b 100644 --- a/tools/content_packs.py +++ b/tools/content_packs.py @@ -74,6 +74,52 @@ def build_pack(src_dir: Path, out_zip: Path) -> dict: return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)} +# rig_builder ships "fat" .vst3 bundles carrying all three platforms inside +# Contents/. A pack for one platform keeps that platform's binary dir + the +# shared bundle files, and drops the other two. +VST_PLATFORM_DIRS = {"mac": "MacOS", "win": "x86_64-win", "linux": "x86_64-linux"} + + +def build_vst_pack(vst_root: Path, out_zip: Path, platform: str) -> dict: + """Reproducibly zip the VST tree keeping only `platform`'s binaries. + + Slices each fat .vst3: everything is kept except the two foreign platform + dirs (MacOS / x86_64-win / x86_64-linux) and the vst/src build trees. Arc + names are relative to vst_root so the download endpoint extracts straight + into /vst/. Same reproducible-build guarantees as build_pack. + """ + if platform not in VST_PLATFORM_DIRS: + raise ValueError(f"unknown platform {platform!r} (want mac/win/linux)") + foreign = set(VST_PLATFORM_DIRS.values()) - {VST_PLATFORM_DIRS[platform]} + files = [] + for p in sorted(vst_root.rglob("*"), key=lambda q: q.as_posix()): + if not p.is_file(): + continue + rel = p.relative_to(vst_root) + if rel.parts and rel.parts[0] == "src": # skip C++/JUCE build trees + continue + if set(rel.parts) & foreign: # drop foreign-platform binaries + continue + files.append((p, rel)) + if not files: + raise ValueError(f"no VST files to pack for {platform} in {vst_root}") + out_zip.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf: + for p, rel in files: + info = zipfile.ZipInfo(rel.as_posix(), date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_STORED + # Pin create_system like build_pack: ZipInfo defaults it from the + # host OS (0 on Windows, 3 on Unix), which would otherwise make the + # same pack hash differently across runners. VST packs are the most + # likely to be built on Windows (native .vst3), so without this pin + # the precomputable-hash guarantee breaks exactly where it's needed. + info.create_system = 3 + info.external_attr = 0o644 << 16 + zf.writestr(info, p.read_bytes()) + data = out_zip.read_bytes() + return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)} + + def manifest_entry(out_zip: Path, url: str) -> dict: """Pack info as the download-path expects it: {url, sha256, bytes}.""" return {"url": url, @@ -96,25 +142,47 @@ def pack_url(pack_id: str, version: int, repo: str = REPO) -> str: f"{pack_tag(pack_id, version)}/{pack_asset(pack_id, version)}") -def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None: +# VST packs use the same immutable per-pack convention, keyed by platform: +# tag `vst--v`, asset `vst--pack-v.zip`. The manifest they +# emit is keyed by platform (mac/win/linux) — the shape the rig_builder plugin's +# data/vst_packs.json consumes. +def vst_tag(platform: str, version: int) -> str: + return f"vst-{platform}-v{version}" + + +def vst_asset(platform: str, version: int) -> str: + return f"vst-{platform}-pack-v{version}.zip" + + +def vst_url(platform: str, version: int, repo: str = REPO) -> str: + return (f"https://github.com/{repo}/releases/download/" + f"{vst_tag(platform, version)}/{vst_asset(platform, version)}") + + +def _publish_release(tag: str, zip_path: Path, title: str, notes: str, + repo: str = REPO) -> None: """Create the per-pack release if missing, then upload the versioned zip. Tags are immutable: a media change means a new version (v1 → v2), never a re-upload — so no --clobber. gh errors if the asset already exists, which is the right guard against overwriting a published, referenced pack. """ - tag = pack_tag(pack_id, version) if subprocess.run(["gh", "release", "view", tag, "--repo", repo], capture_output=True).returncode != 0: subprocess.run( ["gh", "release", "create", tag, "--repo", repo, "--latest=false", - "--title", f"{pack_id.capitalize()} venue pack v{version}", - "--notes", "Opt-in career venue pack. Not a code release."], + "--title", title, "--notes", notes], check=True) subprocess.run( ["gh", "release", "upload", tag, str(zip_path), "--repo", repo], check=True) +def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None: + _publish_release(pack_tag(pack_id, version), zip_path, + f"{pack_id.capitalize()} venue pack v{version}", + "Opt-in career venue pack. Not a code release.", repo) + + def _pack_id(src_dir: Path) -> str: return src_dir.name @@ -129,6 +197,10 @@ def main(argv=None) -> int: help="write zips here + a file:// manifest.json; no upload") ap.add_argument("--publish", action="store_true", help="create/upload the per-pack release; emit release URLs") + ap.add_argument("--vst", action="store_true", + help="slice one rig VST root (src[0]) into per-platform " + "vst--v packs; manifest keyed by platform " + "(the shape rig_builder's data/vst_packs.json wants)") ap.add_argument("--manifest", type=Path, help="write the {id: {url,sha256,bytes}} map here (default: stdout)") ap.add_argument("--selfcheck", action="store_true", help="run the round-trip demo and exit") @@ -141,16 +213,30 @@ def main(argv=None) -> int: out_dir = args.local if args.local else Path(args.src[0]).parent / "_packs" manifest = {} - for src in args.src: - pid = _pack_id(src) - zip_path = out_dir / pack_asset(pid, args.version) - build_pack(src, zip_path) - if args.publish: - publish(pid, args.version, zip_path) - url = pack_url(pid, args.version) - else: - url = (out_dir.resolve() / zip_path.name).as_uri() - manifest[pid] = manifest_entry(zip_path, url) + if args.vst: + vst_root = args.src[0] + for plat in VST_PLATFORM_DIRS: + zip_path = out_dir / vst_asset(plat, args.version) + build_vst_pack(vst_root, zip_path, plat) + if args.publish: + _publish_release(vst_tag(plat, args.version), zip_path, + f"Rig VST pack ({plat}) v{args.version}", + "Opt-in per-platform rig VST pack. Not a code release.") + url = vst_url(plat, args.version) + else: + url = (out_dir.resolve() / zip_path.name).as_uri() + manifest[plat] = manifest_entry(zip_path, url) + else: + for src in args.src: + pid = _pack_id(src) + zip_path = out_dir / pack_asset(pid, args.version) + build_pack(src, zip_path) + if args.publish: + publish(pid, args.version, zip_path) + url = pack_url(pid, args.version) + else: + url = (out_dir.resolve() / zip_path.name).as_uri() + manifest[pid] = manifest_entry(zip_path, url) out = json.dumps(manifest, indent=2) if args.manifest: @@ -183,6 +269,23 @@ def _selfcheck() -> int: with zipfile.ZipFile(zip_path) as zf: names = zf.namelist() assert set(names) == {"manifest.json", "bored.mp4"}, names + + # VST slice: keep target platform + shared, drop foreign, reproducible. + c = td / "vst" / "Foo.vst3" / "Contents" + for d in ("MacOS", "x86_64-win", "x86_64-linux", "Resources"): + (c / d).mkdir(parents=True) + (c / "MacOS" / "Foo").write_bytes(b"mac") + (c / "x86_64-linux" / "Foo.so").write_bytes(b"linux") + (c / "Info.plist").write_bytes(b"") + vzip = td / vst_asset("linux", 1) + vinfo = build_vst_pack(td / "vst", vzip, "linux") + assert vinfo == build_vst_pack(td / "vst", td / "v2.zip", "linux"), \ + "vst slice is not reproducible" + with zipfile.ZipFile(vzip) as zf: + vnames = set(zf.namelist()) + assert "Foo.vst3/Contents/x86_64-linux/Foo.so" in vnames + assert "Foo.vst3/Contents/Info.plist" in vnames + assert not any("MacOS" in n for n in vnames), vnames print("content_packs selfcheck: ok") return 0