mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
ship-ci / ci (push) Waiting to run
Rebased onto merged main (was stacked on #1023/#1024, whose venue work is
now in main) so it no longer carries a stale content_packs.py that would
revert 1023's build_pack fixes.
- build_vst_pack: slice a fat .vst3 tree to one platform (keep its binary
dir + shared bundle files, drop the two foreign platform dirs and src/
build trees). Pins create_system=3 like build_pack — without it the same
tree hashes differently on a Windows runner (native .vst3 are built there),
breaking the precomputable-hash guarantee exactly where it matters.
- Publish wiring: 'python tools/content_packs.py <vst-root> --vst --version N
--publish' builds+uploads vst-<plat>-vN releases for mac/win/linux and emits
a platform-keyed {url,sha256,bytes} manifest — the shape rig_builder's
data/vst_packs.json consumes. publish() refactored onto a shared
_publish_release helper (venue behaviour unchanged).
- Tests: slice keeps target+shared/drops foreign, per-platform binary,
reproducibility, unknown-platform reject, and a simulated-win32 guard that
fails if the create_system pin is dropped. selfcheck covers the VST path.
Original build_vst_pack by Matthew Harris Glover; reworked for the create_system
fix, publish wiring, and rebase.
Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
295 lines
13 KiB
Python
295 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Build & publish opt-in content packs (career venue media, rig VST slices).
|
|
|
|
Flat-zips a pack directory, sha256s it, and emits the ``{url, sha256, bytes}``
|
|
block that the career and rig_builder download paths consume
|
|
(``plugins/career/routes.py`` ``_download_pack``). Two modes:
|
|
|
|
--local <dir> write zips + a file:// manifest (dev/CI/tests; no network)
|
|
--publish create/upload each pack's per-pack release; emit release URLs
|
|
|
|
The zip is flat (files at the archive root) to satisfy career's zip-slip guard
|
|
(``PACK_FILENAME_RE``) and ``_validate_pack_dir``. This module is the reusable
|
|
core the content-packs CI workflow calls, so building packs is automation —
|
|
never a person's manual job.
|
|
|
|
Run ``python tools/content_packs.py --selfcheck`` for the built-in round-trip.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
REPO = "got-feedBack/feedBack" # where the content-packs release lives (public)
|
|
|
|
# Must mirror career's download-time whitelist (plugins/career/routes.py
|
|
# PACK_FILENAME_RE). If the builder packs a name the downloader rejects (e.g. a
|
|
# stray .DS_Store), the published pack fails _validate_pack_dir for every client.
|
|
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
|
|
|
|
|
|
def build_pack(src_dir: Path, out_zip: Path) -> dict:
|
|
"""Flat-zip every file directly under src_dir; return {sha256, bytes}.
|
|
|
|
Only regular files at the top level are included (venue packs are flat).
|
|
Subdirectories are skipped — a nested tree would trip career's zip-slip
|
|
guard on download anyway.
|
|
|
|
The build is REPRODUCIBLE: identical file contents always yield a
|
|
byte-identical zip (fixed name order, fixed mtime, fixed permissions,
|
|
ZIP_STORED). So a sha256 computed on any machine matches the zip the CI
|
|
workflow or another contributor produces — anyone can precompute the
|
|
manifest values without having to be the one who uploads the asset.
|
|
"""
|
|
files = sorted((p for p in src_dir.iterdir() if p.is_file()),
|
|
key=lambda p: p.name)
|
|
if not files:
|
|
raise ValueError(f"no files to pack in {src_dir}")
|
|
bad = [p.name for p in files if not PACK_FILENAME_RE.fullmatch(p.name)]
|
|
if bad:
|
|
raise ValueError(
|
|
f"{src_dir}: files the downloader will reject: {bad} "
|
|
f"(allowed: {PACK_FILENAME_RE.pattern})")
|
|
out_zip.parent.mkdir(parents=True, exist_ok=True)
|
|
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
|
|
# ZIP_STORED: the media (mp4/mp3) and .vst3 binaries are already
|
|
# compressed; deflating just burns CPU for ~0 gain.
|
|
for p in files:
|
|
# Fixed mtime (the zip epoch, 1980-01-01) + fixed perms so the
|
|
# bytes don't depend on the checkout's file timestamps.
|
|
info = zipfile.ZipInfo(p.name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
info.compress_type = zipfile.ZIP_STORED
|
|
# Pin create_system: ZipInfo defaults it from the host OS (0 on
|
|
# Windows, 3 on Unix), which would otherwise make the same pack
|
|
# hash differently across runners. 3 = Unix.
|
|
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)}
|
|
|
|
|
|
# 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 <plugin>/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,
|
|
"sha256": hashlib.sha256(out_zip.read_bytes()).hexdigest(),
|
|
"bytes": out_zip.stat().st_size}
|
|
|
|
|
|
# Per-pack, versioned, immutable release convention (matches what the team
|
|
# already published, e.g. tag `venue-arena-v1` / asset `arena-pack-v1.zip`).
|
|
def pack_tag(pack_id: str, version: int) -> str:
|
|
return f"venue-{pack_id}-v{version}"
|
|
|
|
|
|
def pack_asset(pack_id: str, version: int) -> str:
|
|
return f"{pack_id}-pack-v{version}.zip"
|
|
|
|
|
|
def pack_url(pack_id: str, version: int, repo: str = REPO) -> str:
|
|
return (f"https://github.com/{repo}/releases/download/"
|
|
f"{pack_tag(pack_id, version)}/{pack_asset(pack_id, version)}")
|
|
|
|
|
|
# VST packs use the same immutable per-pack convention, keyed by platform:
|
|
# tag `vst-<plat>-v<N>`, asset `vst-<plat>-pack-v<N>.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.
|
|
"""
|
|
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", 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
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("src", nargs="*", type=Path,
|
|
help="pack source dirs (e.g. plugins/career/venue-packs/club)")
|
|
ap.add_argument("--version", type=int, default=1,
|
|
help="pack version (tag venue-<id>-v<N>); default 1")
|
|
ap.add_argument("--local", type=Path, metavar="DIR",
|
|
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-<plat>-v<N> 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")
|
|
args = ap.parse_args(argv)
|
|
|
|
if args.selfcheck:
|
|
return _selfcheck()
|
|
if not args.src or (not args.local and not args.publish):
|
|
ap.error("need one or more src dirs and either --local or --publish")
|
|
|
|
out_dir = args.local if args.local else Path(args.src[0]).parent / "_packs"
|
|
manifest = {}
|
|
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:
|
|
args.manifest.write_text(out + "\n", encoding="utf-8")
|
|
else:
|
|
print(out)
|
|
return 0
|
|
|
|
|
|
def _selfcheck() -> int:
|
|
"""Build a pack and confirm build_pack/manifest_entry agree on the digest."""
|
|
import tempfile
|
|
with tempfile.TemporaryDirectory() as td:
|
|
td = Path(td)
|
|
src = td / "bar"
|
|
src.mkdir()
|
|
(src / "manifest.json").write_text('{"venue":"bar"}')
|
|
(src / "bored.mp4").write_bytes(b"\x00fake-video")
|
|
zip_path = td / pack_asset("bar", 1)
|
|
info = build_pack(src, zip_path)
|
|
# Reproducible: a second build (into a different path) is byte-identical.
|
|
info2 = build_pack(src, td / "again.zip")
|
|
assert info2["sha256"] == info["sha256"], "build is not reproducible"
|
|
entry = manifest_entry(zip_path, pack_url("bar", 1))
|
|
assert entry["sha256"] == info["sha256"], "digest mismatch"
|
|
assert entry["bytes"] == info["bytes"]
|
|
assert entry["url"] == (
|
|
f"https://github.com/{REPO}/releases/download/venue-bar-v1/bar-pack-v1.zip")
|
|
# Round-trip: the zip must be flat (names == basenames).
|
|
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"<plist/>")
|
|
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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|