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 <noreply@anthropic.com>
This commit is contained in:
Alex Vanderveen
2026-09-30 21:15:57 -04:00
parent efcb48fbcb
commit 11df3be1ff
7 changed files with 631 additions and 0 deletions
+27
View File
@@ -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
+3
View File
@@ -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:
+77
View File
@@ -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())
@@ -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]],
*,
@@ -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")
@@ -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))
@@ -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)