From 11df3be1ffcabd4832b9f3c84284245b8e64e084 Mon Sep 17 00:00:00 2001 From: Alex Vanderveen Date: Wed, 30 Sep 2026 21:15:57 -0400 Subject: [PATCH] tools: migrate_bundled_release.py moves an existing title onto bundled releases One pass per title: bump psxrecomp/recomp-ui to a ref that carries docs/ci/templates/game-release.yml and the committed BIOS backends (refused otherwise), un-ignore generated/, ensure [runtime] overlay_cache = true, emit scripts/package_release.sh (retiring package_setup_release.sh) and release.yml from the pinned template, rebuild emitters + regenerate the game C from the title's disc, refresh framework_pins.txt, run check_boot_exe and check_generated, commit; --push optional, --dry-run writes nothing. The operation lives in project_studio/migrate_bundled.py; the tools/ script, `git migrate-bundled` and `git bulk-migrate-bundled` (map_repos, per-repo streaming) are its faces. Exercised end to end on a scratch copy of Bomberman Party Edition (48 generated files committed, zip prefix kept, generate_ci --check current). Co-Authored-By: Claude Fable 5.1 --- docs/ci/BUNDLED_RELEASES.md | 27 ++ docs/ci/README.md | 3 + tools/migrate_bundled_release.py | 77 +++++ .../project_studio/bulkops.py | 33 ++ .../new_project_layout/project_studio/cli.py | 90 +++++ .../project_studio/migrate_bundled.py | 319 ++++++++++++++++++ tools/tests/test_bundled_release_layout.py | 82 +++++ 7 files changed, 631 insertions(+) create mode 100755 tools/migrate_bundled_release.py create mode 100644 tools/new_project_layout/project_studio/migrate_bundled.py diff --git a/docs/ci/BUNDLED_RELEASES.md b/docs/ci/BUNDLED_RELEASES.md index 6ca0a70c..a3193647 100644 --- a/docs/ci/BUNDLED_RELEASES.md +++ b/docs/ci/BUNDLED_RELEASES.md @@ -92,6 +92,33 @@ link-only rebuild of the tens of MB of generated C. | Update | Extract the new zip over the old one (raw zip extract: this is a prebuilt Play binary). Saves and settings live beside the exe and are preserved. | | Retail BIOS | The SCPH-1001 backend is compiled in; a player who owns that dump picks it in the launcher and it hot-swaps (the image is validated, never shipped). Another known image is built into a backend on their machine, once (`docs/BIOS_SELECTION.md`). | +## Migrating a title that predates this + +A title still on the setup-host shape (a `release.yml` that wipes `generated/`, +`scripts/package_setup_release.sh`, `generated/` ignored, a psxrecomp pin +without the BIOS backends) is moved in one pass by +`tools/migrate_bundled_release.py`, run from the framework checkout the title +should pin: + +```sh +python3 psxrecomp/tools/migrate_bundled_release.py . --dry-run # report only +python3 psxrecomp/tools/migrate_bundled_release.py . # commit locally +python3 psxrecomp/tools/migrate_bundled_release.py . --push # and push +``` + +In order: bump `psxrecomp` and `recomp-ui` to `--psxrecomp-ref` / +`--recomp-ui-ref` (default `origin/master`, refused if that ref lacks the +template or the committed BIOS backends), un-ignore `generated/`, ensure +`[runtime] overlay_cache = true`, write `scripts/package_release.sh` (retiring +the setup-host wrapper) and `release.yml` from the pinned template, rebuild the +emitters and regenerate the game C from the disc (`--disc`, else `disc.cfg`, +else the single `disc/*.cue`; `--skip-generate` defers it and the release gate +refuses until done), refresh `framework_pins.txt`, run `check_boot_exe.sh` and +`check_generated.sh`, commit. The working tree must be clean and the +submodules free of local changes. The same operation is Project Studio's +`git migrate-bundled` / `git bulk-migrate-bundled` and Retro Studio's +**Bulk Migrate** tab, which runs it on every ticked title. + ## Title checklist (short) - `generated/` committed and regenerated whenever seeds, `game.toml` codegen diff --git a/docs/ci/README.md b/docs/ci/README.md index ab7c3ab8..ea7de8fb 100644 --- a/docs/ci/README.md +++ b/docs/ci/README.md @@ -21,6 +21,9 @@ and only logs SHAs with `record_pins.sh` (no `verify_pins` gate). workflow; Project Studio (`tools/new_project_layout/migrate_project.py apply` / `gui`) emits the same packager wrapper + `release.yml` and un-ignores `generated/`. +A title still on the retired setup-host shape is moved whole by +`tools/migrate_bundled_release.py` (pins, workflow, packager, regenerate, +commit) -- see `BUNDLED_RELEASES.md`, "Migrating a title that predates this". Manual: diff --git a/tools/migrate_bundled_release.py b/tools/migrate_bundled_release.py new file mode 100755 index 00000000..39817d3d --- /dev/null +++ b/tools/migrate_bundled_release.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""migrate_bundled_release.py -- move an EXISTING PSX title onto bundled +releases: committed generated/ game C, compiled-in BIOS backends, and a +release.yml that builds and ships the compiled game (docs/ci/BUNDLED_RELEASES.md). + + python3 psxrecomp/tools/migrate_bundled_release.py # cwd is the title + python3 psxrecomp/tools/migrate_bundled_release.py ~/src/MyGameRecomp --dry-run + python3 psxrecomp/tools/migrate_bundled_release.py . --disc disc/Game.cue --push + +What it does to the title, in order (each step is reported): + 1. bumps the psxrecomp (and recomp-ui) submodule to --psxrecomp-ref / + --recomp-ui-ref (default origin/master) and refuses a pin that lacks + docs/ci/templates/game-release.yml or the committed BIOS backends + 2. un-ignores generated/, ensures [runtime] overlay_cache = true + 3. writes scripts/package_release.sh (retiring package_setup_release.sh) + and .github/workflows/release.yml from the pinned framework's template + 4. rebuilds the emitters and regenerates the game C from the title's disc + (--disc, else disc.cfg, else the single disc/*.cue); --skip-generate + defers that to you, and the release gate will refuse until it is done + 5. refreshes framework_pins.txt, runs the CI gates locally, commits; + --push also pushes + +This is the thin face of Project Studio's migrate_bundled operation +(tools/new_project_layout/project_studio/migrate_bundled.py), which is also +what `project_studio git migrate-bundled` / `git bulk-migrate-bundled` and +Retro Studio's "Bulk migrate" tab run. The title's working tree must be +clean; the pins are moved inside the submodules, so stash any WIP there first. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(_HERE / "new_project_layout")) + +from project_studio.migrate_bundled import ( # noqa: E402 + BundledMigrateOptions, + migrate_to_bundled_release, +) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("root", nargs="?", default=".", help="Title repository root (default: cwd)") + ap.add_argument("--psxrecomp-ref", default="origin/master") + ap.add_argument("--recomp-ui-ref", default="origin/master") + ap.add_argument("--disc", default="", help="Disc image for generate") + ap.add_argument("--bios", default="", help="BIOS dump for generate (default: bundled OpenBIOS)") + ap.add_argument("--zip-prefix", default="", help="CI zip prefix (default: keep the existing packager's)") + ap.add_argument("--skip-generate", action="store_true", help="Do not regenerate game C") + ap.add_argument("--push", action="store_true", help="Push after committing") + ap.add_argument("--dry-run", action="store_true", help="Report what would change; write nothing") + args = ap.parse_args(argv) + + opts = BundledMigrateOptions( + psxrecomp_ref=args.psxrecomp_ref, + recomp_ui_ref=args.recomp_ui_ref, + regenerate=not args.skip_generate, + disc=args.disc or None, + bios=args.bios or None, + zip_prefix=args.zip_prefix or None, + push_remote=args.push, + dry_run=args.dry_run, + ) + r = migrate_to_bundled_release(Path(args.root), opts) + if r.detail: + for ln in r.detail.splitlines(): + print(f" {ln}") + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + return 0 if r.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/new_project_layout/project_studio/bulkops.py b/tools/new_project_layout/project_studio/bulkops.py index f1dfd3a6..7a395c53 100644 --- a/tools/new_project_layout/project_studio/bulkops.py +++ b/tools/new_project_layout/project_studio/bulkops.py @@ -205,6 +205,39 @@ def bulk_status( return map_repos(repos, one, jobs=jobs, on_repo=on_repo) +def bulk_migrate_bundled( + repos: list[tuple[str, Path]], + *, + psxrecomp_ref: str = "origin/master", + recomp_ui_ref: str = "origin/master", + regenerate: bool = True, + push_remote: bool = False, + dry_run: bool = False, + jobs: int = 1, + on_repo: OnRepoResults | None = None, +) -> list[CmdResult]: + """Move each selected game repo onto bundled releases (committed generated/ + C, compiled-in BIOS backends, bundled release.yml) -- see migrate_bundled. + + Regeneration builds the emitters per repo, so keep ``jobs`` small.""" + from .migrate_bundled import BundledMigrateOptions, migrate_to_bundled_release + + def one(label: str, root: Path) -> list[CmdResult]: + r = migrate_to_bundled_release( + root, + BundledMigrateOptions( + psxrecomp_ref=psxrecomp_ref, + recomp_ui_ref=recomp_ui_ref, + regenerate=regenerate, + push_remote=push_remote, + dry_run=dry_run, + ), + ) + return [CmdResult(r.ok, f"{label}: {r.message}", r.detail)] + + return map_repos(repos, one, jobs=jobs, on_repo=on_repo) + + def bulk_pull( repos: list[tuple[str, Path]], *, diff --git a/tools/new_project_layout/project_studio/cli.py b/tools/new_project_layout/project_studio/cli.py index 9354025c..3d449bf8 100644 --- a/tools/new_project_layout/project_studio/cli.py +++ b/tools/new_project_layout/project_studio/cli.py @@ -1305,6 +1305,57 @@ def cmd_git_bulk_install_ci(args: argparse.Namespace) -> int: return _print_module_results(results) +def _migrate_bundled_common(args: argparse.Namespace) -> dict: + return dict( + psxrecomp_ref=getattr(args, "psxrecomp_ref", "") or "origin/master", + recomp_ui_ref=getattr(args, "recomp_ui_ref", "") or "origin/master", + regenerate=not bool(getattr(args, "skip_generate", False)), + push_remote=bool(getattr(args, "push", False)), + dry_run=bool(getattr(args, "dry_run", False)), + ) + + +def cmd_git_bulk_migrate_bundled(args: argparse.Namespace) -> int: + from project_studio.bulkops import bulk_migrate_bundled + from project_studio.repo_index import load_index + + repos = _bulk_repos_or_die(args) + if repos is None: + return 2 + jobs = int(getattr(args, "jobs", 0) or 0) or int(getattr(load_index(), "bulk_jobs", 1) or 1) + + def on_repo(label: str, results: list) -> None: + for r in results: + print(f" [{'OK' if r.ok else 'FAIL'}] {r.message}", flush=True) + if r.detail: + for ln in r.detail.splitlines(): + print(f" {ln}", flush=True) + + results = bulk_migrate_bundled(repos, jobs=jobs, on_repo=on_repo, **_migrate_bundled_common(args)) + failed = sum(1 for r in results if not r.ok) + print(f"bulk migrate-bundled: {len(results) - failed} ok, {failed} failed", flush=True) + return 1 if failed else 0 + + +def cmd_git_migrate_bundled(args: argparse.Namespace) -> int: + from project_studio.migrate_bundled import BundledMigrateOptions, migrate_to_bundled_release + + root = _root_or_die(args) + if root is None: + return 2 + opts = BundledMigrateOptions( + disc=getattr(args, "disc", None) or None, + bios=getattr(args, "bios", None) or None, + zip_prefix=getattr(args, "zip_prefix", "") or None, + **_migrate_bundled_common(args), + ) + r = migrate_to_bundled_release(root, opts) + print(f"[{'OK' if r.ok else 'FAIL'}] {r.message}") + if r.detail: + print(r.detail) + return 0 if r.ok else 1 + + def cmd_git_release(args: argparse.Namespace) -> int: from project_studio.gitops import run_release_workflow @@ -2221,6 +2272,45 @@ def build_parser() -> argparse.ArgumentParser: ) p_gbci.set_defaults(func=cmd_git_bulk_install_ci) + def add_migrate_bundled_flags(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--psxrecomp-ref", default="origin/master", + help="psxrecomp ref to pin (must carry bundled releases; default origin/master)", + ) + p.add_argument( + "--recomp-ui-ref", default="origin/master", + help="recomp-ui ref to pin (default origin/master)", + ) + p.add_argument( + "--skip-generate", action="store_true", + help="Do not rebuild emitters / regenerate game C (you must before releasing)", + ) + p.add_argument( + "--push", action="store_true", + help="Push the migration commit (default: commit locally only)", + ) + + p_gbmb = git_sub.add_parser( + "bulk-migrate-bundled", + help="Move selected indexed repos onto bundled releases (bump pins, " + "new release.yml + packager, regenerate, commit)", + ) + add_bulk_select(p_gbmb) + add_migrate_bundled_flags(p_gbmb) + p_gbmb.add_argument("--jobs", type=int, default=0, help="Parallel repos (default: index setting)") + p_gbmb.set_defaults(func=cmd_git_bulk_migrate_bundled) + + p_gmb = git_sub.add_parser( + "migrate-bundled", + help="Move one game repo onto bundled releases", + ) + add_git_root(p_gmb) + add_migrate_bundled_flags(p_gmb) + p_gmb.add_argument("--disc", default="", help="Disc .cue/.bin (default: disc.cfg or the one disc/*.cue)") + p_gmb.add_argument("--bios", default="", help="BIOS dump for generate (default: framework bundled OpenBIOS)") + p_gmb.add_argument("--zip-prefix", default="", help="CI asset zip prefix (default: from existing packager)") + p_gmb.set_defaults(func=cmd_git_migrate_bundled) + p_gr = git_sub.add_parser("release", help="gh workflow run release.yml") add_git_root(p_gr) p_gr.add_argument("--version", default="", help="Empty = auto-bump") diff --git a/tools/new_project_layout/project_studio/migrate_bundled.py b/tools/new_project_layout/project_studio/migrate_bundled.py new file mode 100644 index 00000000..e422f8d9 --- /dev/null +++ b/tools/new_project_layout/project_studio/migrate_bundled.py @@ -0,0 +1,319 @@ +"""Move an existing PSX title onto bundled releases (committed generated/ C, +compiled-in BIOS backends, compiled game shipped). + +A title that predates 2026-09-30 carries the setup-host shape: a release.yml +that wipes generated/ and ships an uncompiled kit, scripts/ +package_setup_release.sh, a .gitignore that ignores generated/, and a +psxrecomp pin whose template and BIOS backends are gone. This module turns it +into the bundled shape in one pass, as the CLI (`migrate-bundled`), the bulk +op (`bulk-migrate-bundled`) and retcomm-studio's bulk tab all run it: + + 1. bump the psxrecomp submodule (and recomp-ui, which the new runtime + needs) to a ref that carries docs/ci/templates/game-release.yml and + the committed BIOS backends; refuse a pin that does not + 2. un-ignore generated/ (merge_gitignore), ensure [runtime] + overlay_cache = true (the packager refuses to ship without it) + 3. emit scripts/package_release.sh, retire package_setup_release.sh, + emit .github/workflows/release.yml from the new template + 4. regenerate the game C against the new pin from the title's disc + (build_emitters.sh + psxrecomp_cli.py generate) -- an emitter bump + changes codegen, so C generated under the old pin must not be committed + 5. refresh framework_pins.txt, run the CI gates locally + (check_boot_exe.sh, check_generated.sh), stage, commit, optionally push + +Everything is reported per step in CmdResult.detail so a bulk run over many +titles reads as a ledger. Dry-run performs no git mutation and no generate. +""" + +from __future__ import annotations + +import glob +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +from .gitops import CmdResult, _git, _is_git_repo, ensure_actions_registers_release_yml, push +from .models import MigrateOptions + +PACKAGER_WRAPPER = "package_release.sh" +LEGACY_PACKAGER_WRAPPER = "package_setup_release.sh" + + +@dataclass +class BundledMigrateOptions: + psxrecomp_ref: str = "origin/master" + recomp_ui_ref: str = "origin/master" + regenerate: bool = True + disc: str | None = None + bios: str | None = None + zip_prefix: str | None = None + push_remote: bool = False + dry_run: bool = False + extra_notes: list[str] = field(default_factory=list) + + +def _run(cmd: list[str], cwd: Path, *, env: dict | None = None) -> tuple[int, str]: + r = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True, + encoding="utf-8", errors="replace", env=env, check=False) + return r.returncode, (r.stdout + r.stderr) + + +def zip_prefix_from_wrapper(root: Path) -> str: + for name in (PACKAGER_WRAPPER, LEGACY_PACKAGER_WRAPPER): + try: + text = (root / "scripts" / name).read_text(encoding="utf-8", errors="replace") + except OSError: + continue + m = re.search(r"--zip-prefix\s+([^\s\\'\"]+)", text) + if m: + return m.group(1) + return "" + + +def find_disc(root: Path, explicit: str | None) -> Path | None: + """The title's disc image: --disc, else disc.cfg, else the one disc/*.cue.""" + if explicit: + p = Path(explicit).expanduser() + if not p.is_absolute(): + p = root / p + return p if p.is_file() else None + cfg = root / "disc.cfg" + if cfg.is_file(): + line = cfg.read_text(encoding="utf-8", errors="replace").strip().splitlines() + if line: + p = Path(line[0].strip()) + if not p.is_absolute(): + p = root / p + if p.is_file(): + return p + cues = sorted(glob.glob(str(root / "disc" / "*.cue"))) + if len(cues) == 1: + return Path(cues[0]) + return None + + +def ensure_overlay_cache_key(root: Path, dry_run: bool) -> tuple[bool, str]: + """Add `overlay_cache = true` under [runtime] when absent. Returns (changed, note).""" + p = root / "game.toml" + if not p.is_file(): + return False, "game.toml missing" + text = p.read_text(encoding="utf-8", errors="replace") + if re.search(r"^\s*overlay_cache\s*=\s*true", text, re.M): + return False, "overlay_cache = true already set" + if re.search(r"^\s*overlay_cache\s*=\s*false", text, re.M): + return False, "overlay_cache = false is set explicitly; left alone (the packager will need --ship-without-overlay-cache-key-because)" + block = ("# Enable the overlay loader: the shipped static shard / bundled overlay\n" + "# toolchain turn streamed overlays native instead of running them interpreted.\n" + "overlay_cache = true\n") + m = re.search(r"^\[runtime\]\s*$", text, re.M) + if m: + insert_at = m.end() + # keep the section header's newline + nl = text.find("\n", insert_at) + insert_at = len(text) if nl < 0 else nl + 1 + new = text[:insert_at] + block + text[insert_at:] + else: + new = text.rstrip("\n") + "\n\n[runtime]\n" + block + if not dry_run: + p.write_text(new, encoding="utf-8") + return True, "added [runtime] overlay_cache = true" + + +def _bump_submodule(root: Path, name: str, ref: str, dry_run: bool) -> tuple[bool, str, str]: + """Fetch and check out `ref` in submodule `name`. Returns (ok, sha, note).""" + sub = root / name + if not (sub / ".git").exists(): + code, out = _run(["git", "submodule", "update", "--init", "--", name], root) + if code != 0: + return False, "", f"{name}: submodule init failed: {out.strip()[-300:]}" + code, out = _run(["git", "fetch", "-q", "origin"], sub) + if code != 0: + return False, "", f"{name}: fetch failed: {out.strip()[-300:]}" + code, sha = _run(["git", "rev-parse", "--verify", f"{ref}^{{commit}}"], sub) + if code != 0: + return False, "", f"{name}: ref {ref!r} not found after fetch" + sha = sha.strip() + code, cur = _run(["git", "rev-parse", "HEAD"], sub) + cur = cur.strip() + if cur == sha: + return True, sha, f"{name}: already at {sha[:10]}" + if dry_run: + return True, sha, f"{name}: would move {cur[:10]} -> {sha[:10]}" + code, out = _run(["git", "status", "--porcelain"], sub) + if out.strip(): + return False, sha, f"{name}: working tree has local changes; commit or stash them first:\n{out.strip()[:400]}" + code, out = _run(["git", "checkout", "-q", sha], sub) + if code != 0: + return False, sha, f"{name}: checkout {sha[:10]} failed: {out.strip()[-300:]}" + code, out = _run(["git", "submodule", "update", "--init", "--recursive"], sub) + if code != 0: + return False, sha, f"{name}: nested submodule update failed: {out.strip()[-300:]}" + return True, sha, f"{name}: {cur[:10]} -> {sha[:10]}" + + +def migrate_to_bundled_release(root: Path, opts: BundledMigrateOptions) -> CmdResult: + root = Path(root).expanduser().resolve() + log: list[str] = [] + + def note(s: str) -> None: + log.append(s) + + def fail(msg: str) -> CmdResult: + return CmdResult(False, msg, "\n".join(log)) + + if not _is_git_repo(root): + return fail("not a git repository") + cmake = root / "CMakeLists.txt" + if not cmake.is_file() or "psxrecomp_add_game_runtime" not in cmake.read_text(encoding="utf-8", errors="replace"): + return fail("CMakeLists.txt does not use psxrecomp_add_game_runtime (migrate the layout first: migrate_project.py apply)") + if not (root / "game.toml").is_file(): + return fail("game.toml missing") + code, dirty = _run(["git", "status", "--porcelain", "--untracked-files=no"], root) + if dirty.strip() and not opts.dry_run: + return fail("working tree has uncommitted changes; commit or stash them first:\n" + dirty.strip()[:600]) + + # 1. framework pins + ok, fw_sha, msg = _bump_submodule(root, "psxrecomp", opts.psxrecomp_ref, opts.dry_run) + note(msg) + if not ok: + return fail("psxrecomp bump failed") + fw = root / "psxrecomp" + + def pinned_has(rel: str) -> bool: + # Ask the object store, not the checkout: dry-run leaves the checkout alone. + code, _ = _run(["git", "cat-file", "-e", f"{fw_sha}:{rel}"], fw) + return code == 0 + + if not pinned_has("docs/ci/templates/game-release.yml"): + return fail(f"psxrecomp {fw_sha[:10]} has no docs/ci/templates/game-release.yml; pick a ref at or after bundled releases") + for stem in ("OpenBIOS", "SCPH1001"): + if not pinned_has(f"generated/{stem}_dispatch.c"): + return fail(f"psxrecomp {fw_sha[:10]} does not carry generated/{stem}_dispatch.c; pick a ref that commits its BIOS backends") + if (root / "recomp-ui").is_dir(): + ok, _ui_sha, msg = _bump_submodule(root, "recomp-ui", opts.recomp_ui_ref, opts.dry_run) + note(msg) + if not ok: + return fail("recomp-ui bump failed") + + # Project Studio ops (gitignore / packager / workflow) share the token logic. + from .ops import op_emit_ci_workflow, op_emit_packager, op_merge_gitignore + mopts = MigrateOptions( + zip_prefix=opts.zip_prefix or zip_prefix_from_wrapper(root) or None, + enable_ci=True, force=True, dry_run=opts.dry_run, + ) + + # 2. gitignore + game.toml + r = op_merge_gitignore(root, mopts) + note(f".gitignore: {r.message}") + if not r.ok: + return fail("gitignore merge failed") + gi = root / ".gitignore" + if gi.is_file(): + text = gi.read_text(encoding="utf-8", errors="replace") + stale = "# Setup-host: players Generate game C locally (CI clears generated/).\n" + if stale in text and not opts.dry_run: + gi.write_text(text.replace(stale, "# generated/ is COMMITTED: releases ship the compiled game built from it.\n"), encoding="utf-8") + changed, msg = ensure_overlay_cache_key(root, opts.dry_run) + note(f"game.toml: {msg}") + + # 3. packager + workflow + r = op_emit_packager(root, mopts) + note(f"packager: {r.message}") + if not r.ok: + return fail("packager emit failed") + legacy = root / "scripts" / LEGACY_PACKAGER_WRAPPER + retire_legacy = legacy.is_file() + r = op_emit_ci_workflow(root, mopts) + note(f"workflow: {r.message}") + if not r.ok: + return fail("workflow emit failed") + + # 4. regenerate against the new pin + staged_generated = False + if opts.regenerate: + disc = find_disc(root, opts.disc) + if disc is None: + return fail("no disc image found (pass --disc, or write disc.cfg, or keep exactly one disc/*.cue); " + "or pass --skip-generate and regenerate before the first release") + note(f"disc: {disc}") + if opts.dry_run: + note("would run build_emitters.sh + psxrecomp_cli.py generate") + else: + code, out = _run(["bash", "psxrecomp/tools/ci/build_emitters.sh"], root) + if code != 0: + return fail("build_emitters.sh failed:\n" + out.strip()[-1200:]) + note("emitters built (build-recompiler)") + cmd = [sys.executable, "psxrecomp/psxrecomp_cli.py", "generate", + "--config", "game.toml", "--project-root", ".", "--disc", str(disc)] + if opts.bios: + cmd += ["--bios", str(Path(opts.bios).expanduser().resolve())] + code, out = _run(cmd, root) + tail = "\n".join(ln for ln in out.splitlines() if "opcode 0x2F" not in ln)[-1500:] + if code != 0 or "Generate complete" not in out: + return fail("psxrecomp_cli.py generate failed:\n" + tail) + note("game C regenerated against the new pin") + staged_generated = True + else: + note("regenerate skipped (--skip-generate): generated/ must be regenerated against this pin before a release") + + # 5. pins snapshot, gates, stage, commit + if (root / "framework_pins.txt").is_file() and not opts.dry_run: + code, out = _run(["bash", "psxrecomp/tools/ci/record_pins.sh"], root) + if code == 0: + (root / "framework_pins.txt").write_text(out, encoding="utf-8") + note("framework_pins.txt refreshed") + + if opts.dry_run: + return CmdResult(True, "dry-run: would migrate to bundled releases", "\n".join(log)) + + to_add = [".gitignore", "game.toml", f"scripts/{PACKAGER_WRAPPER}", + ".github/workflows/release.yml", "psxrecomp"] + if (root / "recomp-ui").is_dir(): + to_add.append("recomp-ui") + if (root / "framework_pins.txt").is_file(): + to_add.append("framework_pins.txt") + if (root / "generated").is_dir(): + to_add.append("generated") + code, out, err = _git(root, "add", "--", *to_add) + if code != 0: + return fail("git add failed: " + (err or out).strip()[-400:]) + if retire_legacy: + _git(root, "rm", "-q", "--cached", "--", f"scripts/{LEGACY_PACKAGER_WRAPPER}") + try: + legacy.unlink() + except OSError: + pass + note(f"retired scripts/{LEGACY_PACKAGER_WRAPPER}") + + for gate in ("check_boot_exe.sh", "check_generated.sh --root ."): + code, out = _run(["bash", "-c", f"psxrecomp/tools/ci/{gate}"], root) + if code != 0: + return fail(f"gate {gate.split()[0]} failed (nothing committed; staged changes left for inspection):\n" + out.strip()[-1200:]) + note(f"gate ok: {gate.split()[0]}") + + code, porcelain, _ = _git(root, "diff", "--cached", "--name-only") + if not porcelain.strip(): + return CmdResult(True, "already on bundled releases (nothing to commit)", "\n".join(log)) + msg = (f"release: migrate to bundled releases (psxrecomp {fw_sha[:10]})\n\n" + "Committed generated/ C, compiled-in BIOS backends, compiled game shipped;\n" + "release.yml and scripts/package_release.sh from the psxrecomp template.\n" + + ("Game C regenerated against the new pin.\n" if staged_generated else + "Game C NOT regenerated here (--skip-generate).\n")) + code, out, err = _git(root, "commit", "-q", "-m", msg) + if code != 0: + return fail("git commit failed: " + (err or out).strip()[-400:]) + note(f"committed: {msg.splitlines()[0]}") + if not opts.push_remote: + return CmdResult(True, f"migrated to bundled releases (psxrecomp {fw_sha[:10]}); not pushed", "\n".join(log)) + pr = push(root) + note(pr.message) + if not pr.ok: + return CmdResult(False, "committed but push failed", "\n".join(log) + "\n" + pr.detail) + nudge = ensure_actions_registers_release_yml(root) + if nudge.message: + note(nudge.message) + return CmdResult(True, f"migrated to bundled releases (psxrecomp {fw_sha[:10]}) and pushed", "\n".join(log)) diff --git a/tools/tests/test_bundled_release_layout.py b/tools/tests/test_bundled_release_layout.py index 48fa8930..84bef96a 100644 --- a/tools/tests/test_bundled_release_layout.py +++ b/tools/tests/test_bundled_release_layout.py @@ -317,6 +317,88 @@ class PackagerScriptContract(unittest.TestCase): self.assertEqual(r.returncode, 0, r.stderr) +class MigrateBundledHelpers(unittest.TestCase): + """tools/migrate_bundled_release.py decides from the title's files; pin each decision.""" + + def test_disc_resolution_order(self): + from project_studio.migrate_bundled import find_disc + + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "disc").mkdir() + self.assertIsNone(find_disc(root, None), "no disc -> None, never a guess") + a = root / "disc" / "A.cue" + a.write_text("", encoding="utf-8") + self.assertEqual(find_disc(root, None), a) + (root / "disc" / "B.cue").write_text("", encoding="utf-8") + self.assertIsNone(find_disc(root, None), "two cues is ambiguous") + (root / "disc.cfg").write_text("disc/B.cue\n", encoding="utf-8") + self.assertEqual(find_disc(root, None), root / "disc" / "B.cue") + self.assertEqual(find_disc(root, "disc/A.cue"), a, "--disc wins") + self.assertIsNone(find_disc(root, "disc/missing.cue")) + + def test_overlay_cache_key_is_added_once_and_never_flips_false(self): + from project_studio.migrate_bundled import ensure_overlay_cache_key + + with tempfile.TemporaryDirectory() as td: + root = Path(td) + gt = root / "game.toml" + gt.write_text("[game]\nexe = \"X\"\n\n[runtime]\nbios_hle = true\n", encoding="utf-8") + changed, _ = ensure_overlay_cache_key(root, dry_run=True) + self.assertTrue(changed) + self.assertNotIn("overlay_cache", gt.read_text(encoding="utf-8"), "dry-run writes nothing") + changed, _ = ensure_overlay_cache_key(root, dry_run=False) + self.assertTrue(changed) + text = gt.read_text(encoding="utf-8") + self.assertRegex(text, r"\[runtime\]\n(#[^\n]*\n)*overlay_cache = true\n") + self.assertIn("bios_hle = true", text) + changed, _ = ensure_overlay_cache_key(root, dry_run=False) + self.assertFalse(changed, "idempotent") + gt.write_text("[runtime]\noverlay_cache = false\n", encoding="utf-8") + changed, note = ensure_overlay_cache_key(root, dry_run=False) + self.assertFalse(changed, "an explicit false is the title's decision") + self.assertIn("false", note) + gt.write_text("[game]\nexe = \"X\"\n", encoding="utf-8") + ensure_overlay_cache_key(root, dry_run=False) + self.assertIn("[runtime]\n", gt.read_text(encoding="utf-8"), "section created when absent") + + def test_zip_prefix_is_kept_from_either_wrapper(self): + from project_studio.migrate_bundled import zip_prefix_from_wrapper + + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "scripts").mkdir() + self.assertEqual(zip_prefix_from_wrapper(root), "") + (root / "scripts" / "package_setup_release.sh").write_text( + "exec bash x.sh \\\n --zip-prefix bpe \\\n \"$@\"\n", encoding="utf-8") + self.assertEqual(zip_prefix_from_wrapper(root), "bpe") + (root / "scripts" / "package_release.sh").write_text( + "--zip-prefix newer\n", encoding="utf-8") + self.assertEqual(zip_prefix_from_wrapper(root), "newer", "the live wrapper wins") + + def test_refuses_without_preconditions(self): + from project_studio.migrate_bundled import BundledMigrateOptions, migrate_to_bundled_release + + with tempfile.TemporaryDirectory() as td: + root = Path(td) + r = migrate_to_bundled_release(root, BundledMigrateOptions(dry_run=True)) + self.assertFalse(r.ok) + self.assertIn("not a git repository", r.message) + subprocess.run([GIT, "init", "-q", str(root)], check=True) + (root / "CMakeLists.txt").write_text("project(x)\n", encoding="utf-8") + r = migrate_to_bundled_release(root, BundledMigrateOptions(dry_run=True)) + self.assertFalse(r.ok) + self.assertIn("psxrecomp_add_game_runtime", r.message) + + def test_wrapper_parses_and_documents_itself(self): + p = FW / "tools" / "migrate_bundled_release.py" + self.assertTrue(os.access(p, os.X_OK)) + r = subprocess.run([sys.executable, str(p), "--help"], capture_output=True, text=True) + self.assertEqual(r.returncode, 0, r.stderr) + for flag in ("--psxrecomp-ref", "--skip-generate", "--push", "--dry-run", "--disc"): + self.assertIn(flag, r.stdout) + + if __name__ == "__main__": if GIT is None or BASH is None: print("git and bash are required", file=sys.stderr)