#!/usr/bin/env python3 """Update builds-download-upload-build-ids.json with build IDs at the highest common changelist across all buckets.""" from __future__ import annotations import argparse import json import os import platform import subprocess import sys import tempfile from pathlib import Path _PLATFORM = "windows" if sys.platform == "win32" else "macosx" if sys.platform == "darwin" else "linux" _ARCH = "x64" if sys.platform == "win32" else platform.machine().lower() _EXE_SUFFIX = ".exe" if sys.platform == "win32" else "" _DEFAULT_ZEN = Path(f"build/{_PLATFORM}/{_ARCH}/release/zen{_EXE_SUFFIX}") def _cache_dir() -> Path: if sys.platform == "win32": base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) return base / "Temp" / "zen" elif sys.platform == "darwin": return Path.home() / "Library" / "Caches" / "zen" else: base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) return base / "zen" _OUTPUT_PATH = _cache_dir() / "builds-download-upload-build-ids.json" # Maps build name -> Jupiter bucket _BUILDS: list[tuple[str, str]] = [ ("XB1Client", "fortnitegame.staged-build.fortnite-main.xb1-client"), ("WindowsClient", "fortnitegame.staged-build.fortnite-main.windows-client"), ("SwitchClient", "fortnitegame.staged-build.fortnite-main.switch-client"), ("LinuxServer", "fortnitegame.staged-build.fortnite-main.linux-server"), ("Switch2Client", "fortnitegame.staged-build.fortnite-main.switch2-client"), ("PS4Client", "fortnitegame.staged-build.fortnite-main.ps4-client"), ("PS5Client", "fortnitegame.staged-build.fortnite-main.ps5-client"), ("IOSClient", "fortnitegame.staged-build.fortnite-main.ios-client"), ("AndroidClient", "fortnitegame.staged-build.fortnite-main.android-client"), ] def list_builds_for_bucket(zen: str, host: str, namespace: str, bucket: str) -> list[dict]: """Run zen builds list for a single bucket and return the results array.""" with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp: result_path = Path(tmp.name) cmd = [ zen, "builds", "list", "--namespace", namespace, "--bucket", bucket, "--host", host, "--result-path", str(result_path), ] try: subprocess.run(cmd, check=True, capture_output=True) except FileNotFoundError: sys.exit(f"error: zen binary not found: {zen}") except subprocess.CalledProcessError as e: sys.exit( f"error: zen builds list failed for bucket '{bucket}' with exit code {e.returncode}\n" f"stderr: {e.stderr.decode(errors='replace')}" ) with result_path.open() as f: data = json.load(f) result_path.unlink(missing_ok=True) return data.get("results", []) def main() -> None: parser = argparse.ArgumentParser( description="Refresh builds-download-upload-build-ids.json with build IDs at the highest changelist present in all buckets." ) parser.add_argument("--host", default="https://jupiter.devtools.epicgames.com", help="Jupiter host URL") parser.add_argument("--zen", default=str(_DEFAULT_ZEN), help="Path to the zen binary") parser.add_argument("--namespace", default="fortnite.oplog", help="Builds storage namespace") args = parser.parse_args() # For each bucket, fetch results and build a changelist -> buildId map. # bucket_cl_map[bucket] = { changelist_int: buildId_str, ... } bucket_cl_map: dict[str, dict[int, str]] = {} for name, bucket in _BUILDS: print(f"Querying {name} ({bucket}) ...") results = list_builds_for_bucket(args.zen, args.host, args.namespace, bucket) if not results: sys.exit(f"error: no results for bucket '{bucket}' (build '{name}')") cl_map: dict[int, str] = {} for entry in results: build_id = entry.get("buildId", "") metadata = entry.get("metadata") or {} cl = metadata.get("commit") if build_id and cl is not None: # Keep first occurrence (most recent) per changelist if cl not in cl_map: cl_map[int(cl)] = build_id if not cl_map: sys.exit( f"error: bucket '{bucket}' (build '{name}') returned {len(results)} entries " "but none had both buildId and changelist in metadata" ) print(f" {len(cl_map)} distinct changelists, latest CL {max(cl_map)}") bucket_cl_map[bucket] = cl_map # Find the highest changelist present in every bucket's result set. common_cls = set(next(iter(bucket_cl_map.values())).keys()) for bucket, cl_map in bucket_cl_map.items(): common_cls &= set(cl_map.keys()) if not common_cls: sys.exit( "error: no changelist is present in all buckets.\n" "Per-bucket CL ranges:\n" + "\n".join( f" {name} ({bucket}): {min(bucket_cl_map[bucket])} – {max(bucket_cl_map[bucket])}" for name, bucket in _BUILDS ) ) best_cl = max(common_cls) print(f"\nHighest common changelist: {best_cl}") build_ids: dict[str, dict[str, str]] = {} for name, bucket in _BUILDS: build_id = bucket_cl_map[bucket][best_cl] build_ids[name] = {"bucket": bucket, "buildId": build_id} print(f" {name}: {build_id}") output = {"namespace": args.namespace, "builds": build_ids} _OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) with _OUTPUT_PATH.open("w") as f: json.dump(output, f, indent=2) f.write("\n") print(f"\nWrote {_OUTPUT_PATH}") if __name__ == "__main__": main()