Files
psxrecomp/tools/package_title_release.py
Matthew Stanley 56a6cfe782 Add one shared title release command with a required channel
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:46:47 -07:00

1392 lines
65 KiB
Python
Executable File

#!/usr/bin/env python3
"""Cut a PSXRecomp title release: one command, every platform, both channels.
psxrecomp/tools/package_title_release.py --channel public|playtest
[--config packaging/release.toml] [--platform windows|linux|all]
Run from the title repository root (or pass --root). The title keeps only its
release config (packaging/release.toml, see docs/RELEASING_A_TITLE.md); every
step below is the framework's, so a release fix lands once for every title.
--channel is REQUIRED and has no default:
public public production release. -DPSX_RELEASE_CHANNEL=public, so
PSX_PUBLIC_RELEASE=ON and Playtest Diagnostics ship default off.
playtest non-public playtest package. Diagnostics ship default on and every
artifact name carries "-playtest".
Every audit runs in the matching mode.
What one run owns, in order (any failure ends with one line
"PACKAGING FAILED: <reason>" and a non-zero exit; nothing warns and continues):
1. source provenance: title/framework/recomp-ui commits; refuses a dirty
tracked tree unless --allow-dirty (recorded in the manifest).
2. emitters: psxrecomp-game AND psxrecomp-bios, always, up front.
3. source preparation: recompiled OpenBIOS present (regen if missing),
game C regenerated from the disc, static AOT units when the title's AOT
mode is "static". On Windows, --platform linux/all prepares once here and
the Linux build compiles the same audited sources.
4. configure + build: Release, PSX_DEBUG_TOOLS=OFF, OpenBIOS-only backend
(PSXRECOMP_BIOS_STEMS=OpenBIOS), PSX_RELEASE_CHANNEL=<channel>,
PSX_GAME_VERSION from the title's version file; every owned value is
read back from CMakeCache.txt.
5. stage: executable, assets, OpenBIOS + notice, configs, mod catalog
(verified against the build's own catalog list; developer-channel
features pruned from public packages), overlay cache via the original-
disc AOT pipeline (AOT mode "cache") and/or overlay toolchain, notices,
title docs, START_HERE.txt with @VERSION@ stamped, execution contract.
6. gates: no disc/BIOS dumps/saves/personal state/generated C/build kit;
OpenBIOS only; diagnostics defaults per channel; Windows executable
imports only system DLLs; static AOT receipt matches the linked sources.
7. artifact: Windows ZIP (portable POSIX names) or Linux AppImage
(tools/package_appimage.sh, fixed linuxdeploy/appimagetool pins), its
.sha256, RELEASE_MANIFEST.json inside, then the artifact is re-opened /
extracted and verified (`verify` below) before success is printed.
Linux from Windows: --platform linux (or all) runs the Linux half inside WSL
(default distribution) on the same checkout, from /mnt/<drive>/...
package_title_release.py verify --channel C [--config ...] ARTIFACT
re-checks a finished ZIP or AppImage by extracting it.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import platform as _platform
import re
import shlex
import shutil
import subprocess
import sys
import tempfile
import zipfile
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib # type: ignore
TOOLS = Path(__file__).resolve().parent
FRAMEWORK = TOOLS.parent
sys.path.insert(0, str(TOOLS))
CHANNELS = ('public', 'playtest')
PLATFORMS = ('windows', 'linux')
ARTIFACT_SUFFIX = {'windows': 'windows-x64.zip', 'linux': 'linux-x86_64.AppImage'}
TOOLCHAIN_PLATFORM = {'windows': 'win', 'linux': 'linux'}
AOT_MODES = ('cache', 'static', 'none')
OPENBIOS_SIZE = 524288
CREATE_NO_WINDOW = 0x08000000
# Flags the packager owns. A title's [build].cmake_args may not set them.
OWNED_CMAKE = ('CMAKE_BUILD_TYPE', 'PSX_DEBUG_TOOLS', 'PSX_RELEASE_CHANNEL',
'PSX_PUBLIC_RELEASE', 'PSXRECOMP_BIOS_STEMS', 'PSX_GAME_VERSION',
'PSX_SOURCE_COMMIT', 'PSXRECOMP_ROOT')
# Windows system DLLs a self-contained release executable may import.
SYSTEM_DLLS = frozenset('''
advapi32 bcrypt cfgmgr32 comdlg32 crypt32 d2d1 d3d11 d3d12 dbghelp dinput8 dwmapi
dwrite dxgi gdi32 hid imm32 iphlpapi kernel32 msvcrt ntdll ole32 oleaut32
opengl32 rpcrt4 setupapi shell32 shlwapi ucrtbase user32 userenv uxtheme
version winhttp winmm wintrust ws2_32
'''.split())
# Never in a player package (beyond bios/openbios.bin).
DISC_SUFFIXES = {'.cue', '.iso', '.img', '.chd', '.ccd', '.sub', '.mds', '.mdf',
'.pbp', '.ecm', '.mcd', '.mcr', '.pst', '.pack', '.srm', '.gme'}
PERSONAL_NAMES = {'settings.toml', 'disc.cfg', 'bios.cfg', 'rom.cfg', 'state.toml',
'state.toml.tmp', 'keybinds.ini', 'overlay_captures.json',
'diagnostics-location.txt', 'psx_last_run_report.json',
'psx_crash.txt', 'psx_freeze_heartbeat.json'}
BUILD_KIT = ('psxrecomp', 'recomp-ui', 'generated', 'seeds', 'src', 'disc', 'iso',
'saves', 'toolchain', 'CMakeLists.txt', 'codegen_setup.c',
'codegen_setup.h', 'psxrecomp_cli.py', 'psxrecomp-game',
'psxrecomp-game.exe', 'psxrecomp-bios', 'psxrecomp-bios.exe')
class PackagingError(Exception):
pass
def fail(message):
raise PackagingError(message)
def log(message=''):
print(message, flush=True)
def sha256(path):
digest = hashlib.sha256()
with open(path, 'rb') as stream:
for block in iter(lambda: stream.read(1 << 20), b''):
digest.update(block)
return digest.hexdigest()
def host_platform():
if os.name == 'nt':
return 'windows'
if sys.platform.startswith('linux'):
return 'linux'
fail('unsupported host %s: packaging runs on Windows or Linux' % sys.platform)
def run(command, what, cwd=None, env=None, capture=False):
"""Run a child, stream its output, fail loudly on a non-zero exit.
Output is piped through this process so CREATE_NO_WINDOW never loses it:
no child ever opens a console window on the desktop."""
command = [str(c) for c in command]
log('+ ' + ' '.join(shlex.quote(c) for c in command))
kwargs = dict(cwd=str(cwd) if cwd else None, env=env, stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if os.name == 'nt':
kwargs['creationflags'] = CREATE_NO_WINDOW
try:
process = subprocess.Popen(command, **kwargs)
except OSError as error:
fail('%s: cannot start %s (%s)' % (what, command[0], error))
captured = []
for raw in process.stdout:
line = raw.decode('utf-8', 'replace')
if capture:
captured.append(line)
else:
sys.stdout.write(line)
sys.stdout.flush()
code = process.wait()
if code != 0:
if capture:
sys.stdout.write(''.join(captured))
fail('%s failed (exit %d)' % (what, code))
return ''.join(captured)
def read_text(path):
return Path(path).read_text(encoding='utf-8-sig')
# --------------------------------------------------------------------------
# Config
# --------------------------------------------------------------------------
class Config:
"""packaging/release.toml, validated. Unknown keys are errors (a typo in a
release config must not silently drop a step)."""
SCHEMA = {
'': {'schema', 'product', 'build', 'aot', 'overlay_toolchain', 'diagnostics',
'payload', 'linux', 'hooks'},
'product': {'name', 'display_name', 'exe_name', 'version_file', 'game_toml',
'player_game_toml', 'runtime_target', 'zip_folder', 'launcher_boxart'},
'build': {'cmake_args', 'linux_cmake_args', 'windows_cmake_args', 'regenerate',
'binary_identity'},
'aot': {'mode', 'profile', 'cps'},
'overlay_toolchain': {'ship'},
'diagnostics': {'game_id', 'package_id', 'replay_helper'},
'payload': {'start_here', 'files', 'build_files'},
'linux': {'app_run', 'desktop_file', 'icon', 'payload_dir', 'env_prefix',
'prune_unreferenced_libs'},
'hooks': {'prepare', 'post_build', 'post_stage'},
}
def __init__(self, path, root):
self.path = Path(path)
self.root = Path(root)
if not self.path.is_file():
fail('release config not found: %s' % self.path)
try:
data = tomllib.loads(read_text(self.path))
except (tomllib.TOMLDecodeError, UnicodeError) as error:
fail('%s: %s' % (self.path, error))
self._check_keys(data)
if data.get('schema') != 1:
fail('%s: schema must be 1' % self.path)
product = self._table(data, 'product', required=True)
self.name = self._name(product, 'name')
self.display_name = self._str(product, 'display_name', self.name)
self.exe_name = product.get('exe_name')
if self.exe_name is not None:
self._name(product, 'exe_name')
self.version_file = self._file(product, 'version_file', required=True)
self.game_toml = self._file(product, 'game_toml', default='game.toml')
self.player_game_toml = self._file(product, 'player_game_toml',
default=product.get('game_toml', 'game.toml'))
self.runtime_target = self._str(product, 'runtime_target', 'psx-runtime')
self.zip_folder = self._str(product, 'zip_folder', '')
if self.zip_folder:
self._name(product, 'zip_folder')
self.launcher_boxart = self._file(product, 'launcher_boxart')
build = self._table(data, 'build')
self.cmake_args = self._args(build, 'cmake_args')
self.platform_cmake_args = {'linux': self._args(build, 'linux_cmake_args'),
'windows': self._args(build, 'windows_cmake_args')}
self.regenerate = self._bool(build, 'regenerate', True)
self.binary_identity = self._str(build, 'binary_identity', '')
aot = self._table(data, 'aot')
self.aot_mode = self._str(aot, 'mode', 'none')
if self.aot_mode not in AOT_MODES:
fail('%s: [aot] mode must be one of %s' % (self.path, ', '.join(AOT_MODES)))
self.aot_profile = self._file(aot, 'profile', required=self.aot_mode != 'none')
self.aot_cps = self._bool(aot, 'cps', self.aot_mode == 'static')
self.ship_toolchain = self._bool(self._table(data, 'overlay_toolchain'), 'ship', False)
diag = self._table(data, 'diagnostics')
self.diagnostics = None
if diag:
self.diagnostics = dict(game_id=self._str(diag, 'game_id', required=True),
package_id=self._str(diag, 'package_id', required=True),
replay_helper=self._bool(diag, 'replay_helper', True))
payload = self._table(data, 'payload', required=True)
self.start_here = self._file(payload, 'start_here', required=True)
self.files = []
for item in payload.get('files', []):
if isinstance(item, str):
item = {'from': item, 'to': item}
if not isinstance(item, dict) or set(item) - {'from', 'to'} or 'from' not in item:
fail('%s: [payload] files entries are "path" or {from=, to=}' % self.path)
source = self.root / item['from']
if not source.is_file():
fail('%s: payload file not found: %s' % (self.path, item['from']))
self.files.append((source, self._relpath(item.get('to', item['from']))))
self.build_files = [self._relpath(p) for p in payload.get('build_files', [])]
linux = self._table(data, 'linux')
self.linux = None
if linux:
self.linux = dict(app_run=self._file(linux, 'app_run', required=True),
desktop_file=self._file(linux, 'desktop_file', required=True),
icon=self._file(linux, 'icon', required=True),
payload_dir=self._name(linux, 'payload_dir'),
env_prefix=self._str(linux, 'env_prefix', ''),
prune_libs=self._bool(linux, 'prune_unreferenced_libs', False))
hooks = self._table(data, 'hooks')
self.hooks = {}
for phase in ('prepare', 'post_build', 'post_stage'):
entries = hooks.get(phase, [])
if not isinstance(entries, list):
fail('%s: [[hooks.%s]] must be an array of tables' % (self.path, phase))
self.hooks[phase] = []
for entry in entries:
if not isinstance(entry, dict) or set(entry) - {'command', 'platforms'}:
fail('%s: hooks.%s entries take command = [...] and optional platforms'
% (self.path, phase))
command = entry.get('command')
if (not isinstance(command, list) or not command or
not all(isinstance(c, str) for c in command)):
fail('%s: hooks.%s command must be a non-empty list of strings'
% (self.path, phase))
platforms = entry.get('platforms', list(PLATFORMS))
if not set(platforms) <= set(PLATFORMS):
fail('%s: hooks.%s platforms must be within %s' % (self.path, phase, PLATFORMS))
self.hooks[phase].append((command, platforms))
for args in [self.cmake_args] + list(self.platform_cmake_args.values()):
for arg in args:
match = re.match(r'^-D([A-Za-z0-9_]+)', arg)
if match and match.group(1) in OWNED_CMAKE:
fail('%s: [build] cmake args may not set %s; the packager owns it '
'(channel via --channel)' % (self.path, match.group(1)))
def _check_keys(self, data):
unknown = set(data) - self.SCHEMA['']
for table, allowed in self.SCHEMA.items():
if table and isinstance(data.get(table), dict):
unknown |= {'%s.%s' % (table, k) for k in set(data[table]) - allowed}
if unknown:
fail('%s: unknown key(s): %s' % (self.path, ', '.join(sorted(unknown))))
def _table(self, data, key, required=False):
value = data.get(key)
if value is None:
if required:
fail('%s: missing [%s]' % (self.path, key))
return {}
if not isinstance(value, dict):
fail('%s: %s must be a table' % (self.path, key))
return value
def _str(self, table, key, default=None, required=False):
value = table.get(key, default)
if value is None or (required and value == ''):
fail('%s: missing %s' % (self.path, key))
if not isinstance(value, str):
fail('%s: %s must be a string' % (self.path, key))
return value
def _bool(self, table, key, default):
value = table.get(key, default)
if not isinstance(value, bool):
fail('%s: %s must be true or false' % (self.path, key))
return value
def _name(self, table, key):
value = self._str(table, key, required=True)
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]*', value):
fail('%s: %s %r must be a plain file name' % (self.path, key, value))
return value
def _args(self, table, key):
value = table.get(key, [])
if not isinstance(value, list) or not all(isinstance(a, str) for a in value):
fail('%s: %s must be a list of strings' % (self.path, key))
return value
def _relpath(self, value):
path = PurePosixPath(value)
if path.is_absolute() or '..' in path.parts or not path.parts or ':' in value:
fail('%s: %r must be a relative path inside the package' % (self.path, value))
return path.as_posix()
def _file(self, table, key, default=None, required=False):
value = table.get(key, default)
if value is None:
if required:
fail('%s: missing %s' % (self.path, key))
return None
if not isinstance(value, str):
fail('%s: %s must be a string' % (self.path, key))
path = self.root / value
if not path.is_file():
fail('%s: %s not found: %s' % (self.path, key, value))
return path
def version(self):
raw = read_text(self.version_file).strip()
if not raw or not re.fullmatch(r'v?[0-9][0-9A-Za-z.+-]*', raw):
fail('%s: invalid version %r' % (self.version_file, raw))
return raw
def artifact_name(config, version, channel, plat):
bare = version[1:] if version.startswith('v') else version
suffix = '-playtest' if channel == 'playtest' else ''
return '%s-v%s%s-%s' % (config.name, bare, suffix, ARTIFACT_SUFFIX[plat])
# --------------------------------------------------------------------------
# Provenance
# --------------------------------------------------------------------------
def git_executable():
explicit = os.environ.get('PSX_RELEASE_GIT')
if explicit:
return explicit
if os.name == 'nt':
# PowerShell's `git` is often devkitPro MSYS git, which cannot read a
# worktree whose .git names an F:/ path.
for candidate in (r'C:\Program Files\Git\cmd\git.exe',):
if os.path.isfile(candidate):
return candidate
found = shutil.which('git')
if not found:
fail('git not found (set PSX_RELEASE_GIT)')
return found
def git(repo, *args):
return run([git_executable(), '-C', repo] + list(args), 'git %s' % ' '.join(args),
capture=True).strip()
def provenance(root, ui_root):
"""Commits and cleanliness, from git or (inside WSL) from the Windows parent."""
env = os.environ
if env.get('PSX_RELEASE_SOURCE_COMMIT'):
return dict(source_commit=env['PSX_RELEASE_SOURCE_COMMIT'],
source_date_epoch=int(env.get('PSX_RELEASE_SOURCE_DATE_EPOCH') or 0),
source_dirty=env.get('PSX_RELEASE_SOURCE_DIRTY') == '1',
dirty_paths=json.loads(env.get('PSX_RELEASE_DIRTY_PATHS', '[]')),
framework_commit=env['PSX_RELEASE_FRAMEWORK_COMMIT'],
recomp_ui_commit=env.get('PSX_RELEASE_UI_COMMIT') or None)
status = git(root, 'status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none')
dirty = [line[3:] for line in status.splitlines() if line.strip()]
fw_status = git(FRAMEWORK, 'status', '--porcelain', '--untracked-files=no')
dirty += ['psxrecomp/' + line[3:] for line in fw_status.splitlines() if line.strip()]
ui_commit = None
if ui_root and (Path(ui_root) / '.git').exists():
ui_commit = git(ui_root, 'rev-parse', 'HEAD')
return dict(source_commit=git(root, 'rev-parse', 'HEAD'),
source_date_epoch=int(git(root, 'log', '-1', '--format=%ct')),
source_dirty=bool(dirty),
dirty_paths=dirty, framework_commit=git(FRAMEWORK, 'rev-parse', 'HEAD'),
recomp_ui_commit=ui_commit)
# --------------------------------------------------------------------------
# Toolchain
# --------------------------------------------------------------------------
class Toolchain:
def __init__(self, plat, toolchain_bin):
self.platform = plat
self.env = dict(os.environ)
if plat == 'windows':
self.bin = Path(toolchain_bin)
for tool in ('cmake.exe', 'ninja.exe', 'gcc.exe', 'objdump.exe'):
if not (self.bin / tool).is_file():
fail('%s missing from %s (pass --toolchain-bin)' % (tool, self.bin))
self.env['PATH'] = str(self.bin) + os.pathsep + self.env.get('PATH', '')
self.cmake = str(self.bin / 'cmake.exe')
self.gcc = str(self.bin / 'gcc.exe')
self.objdump = str(self.bin / 'objdump.exe')
self.exe_suffix = '.exe'
else:
for tool in ('cmake', 'gcc'):
if not shutil.which(tool):
fail('%s not found on PATH' % tool)
self.cmake, self.gcc, self.objdump = 'cmake', 'gcc', None
self.exe_suffix = ''
self.generator = 'Ninja' if (plat == 'windows' or shutil.which('ninja')) else 'Unix Makefiles'
def hook_tokens(ctx, **extra):
tokens = dict(python=sys.executable, root=str(ctx.root), framework=str(FRAMEWORK),
channel=ctx.channel, platform=ctx.platform, version=ctx.version,
build_dir=str(ctx.runtime_build), work_dir=str(ctx.work))
tokens.update(extra)
return tokens
def run_hooks(ctx, phase, **extra):
for command, platforms in ctx.config.hooks[phase]:
if ctx.platform not in platforms:
continue
tokens = hook_tokens(ctx, **extra)
try:
argv = [part.format(**tokens) for part in command]
except KeyError as error:
fail('hooks.%s: unknown token %s' % (phase, error))
run(argv, 'hooks.%s %s' % (phase, ' '.join(command[:2])), cwd=ctx.root, env=ctx.tc.env)
# --------------------------------------------------------------------------
# Build steps
# --------------------------------------------------------------------------
class Context:
pass
def build_emitters(ctx):
build = ctx.work / 'recompiler'
run([ctx.tc.cmake, '-S', FRAMEWORK / 'recompiler', '-B', build, '-G', ctx.tc.generator,
'-DCMAKE_BUILD_TYPE=Release'], 'emitter configure', env=ctx.tc.env)
run([ctx.tc.cmake, '--build', build, '--target', 'psxrecomp-game', 'psxrecomp-bios',
'-j', ctx.jobs], 'emitter build', env=ctx.tc.env)
for name in ('psxrecomp-game', 'psxrecomp-bios'):
if not (build / (name + ctx.tc.exe_suffix)).is_file():
fail('emitter build did not produce %s' % (build / (name + ctx.tc.exe_suffix)))
ctx.recompiler_dir = build
ctx.recompiler = build / ('psxrecomp-game' + ctx.tc.exe_suffix)
def ensure_openbios(ctx):
if (FRAMEWORK / 'generated/OpenBIOS_dispatch.c').is_file():
return
log('Recompiled OpenBIOS backend missing; generating it')
if not getattr(ctx, 'recompiler_dir', None):
build_emitters(ctx)
rel_build = os.path.relpath(ctx.recompiler_dir, FRAMEWORK).replace(os.sep, '/')
if ctx.platform == 'windows':
bash = Path(r'C:\msys64\usr\bin\bash.exe')
if not bash.is_file():
fail('OpenBIOS C is missing and MSYS2 bash is not installed to regenerate it')
posix = lambda p: run([str(bash.parent / 'cygpath.exe'), '-u', str(p)], 'cygpath',
capture=True).strip()
script = ("export PATH='%s':/usr/bin:$PATH; cd '%s' && PSXRECOMP_BIOS_BUILD='%s' "
"tools/regen_bios.sh --config bios/OpenBIOS.toml"
% (posix(ctx.tc.bin), posix(FRAMEWORK), rel_build))
run([bash, '-c', script], 'regen_bios OpenBIOS', env=ctx.tc.env)
else:
env = dict(ctx.tc.env, PSXRECOMP_BIOS_BUILD=rel_build)
run(['bash', 'tools/regen_bios.sh', '--config', 'bios/OpenBIOS.toml'],
'regen_bios OpenBIOS', cwd=FRAMEWORK, env=env)
# regen_bios.sh has exited 0 on failure before; check the product.
if not (FRAMEWORK / 'generated/OpenBIOS_dispatch.c').is_file():
fail('regen_bios produced no generated/OpenBIOS_dispatch.c')
def prepare_sources(ctx):
"""Platform-independent source generation: game C and static AOT units."""
config = ctx.config
if config.regenerate:
run([ctx.recompiler, '--config', config.game_toml], 'game C regeneration',
cwd=ctx.root, env=ctx.tc.env)
if config.aot_mode == 'static':
run([sys.executable, FRAMEWORK / 'tools/aot_overlay_pipeline.py', 'static',
'--profile', config.aot_profile, '--game-toml', config.game_toml,
'--recompiler', ctx.recompiler, '--work-dir', ctx.work / 'aot-static',
'--gcc', ctx.tc.gcc, '--cmake', ctx.tc.cmake, '--workers', ctx.jobs, '--reuse']
+ (['--cps'] if config.aot_cps else []),
'static AOT generation', cwd=ctx.root, env=ctx.tc.env)
run_hooks(ctx, 'prepare')
def cmake_cache(build):
path = Path(build) / 'CMakeCache.txt'
if not path.is_file():
fail('no CMakeCache.txt in %s' % build)
values = {}
for line in path.read_text(encoding='utf-8', errors='replace').splitlines():
match = re.match(r'^([A-Za-z0-9_.+-]+):[A-Z_]+=(.*)$', line)
if match:
values[match.group(1)] = match.group(2)
return values
def cmake_bool(value):
return str(value).upper() in ('ON', '1', 'TRUE', 'YES', 'Y')
def configure_and_build(ctx):
owned = {
'CMAKE_BUILD_TYPE': 'Release',
'PSX_DEBUG_TOOLS': 'OFF',
'PSX_RELEASE_CHANNEL': ctx.channel,
'PSXRECOMP_BIOS_STEMS': 'OpenBIOS',
'PSX_GAME_VERSION': ctx.version,
'PSX_SOURCE_COMMIT': ctx.prov['source_commit'],
# The framework running this script is the framework that gets built.
'PSXRECOMP_ROOT': FRAMEWORK.as_posix(),
}
args = ['-D%s=%s' % kv for kv in owned.items()]
args += ctx.config.cmake_args + ctx.config.platform_cmake_args[ctx.platform]
if ctx.platform == 'linux':
# The interpreter running this script; WSL otherwise finds the Windows
# pyenv shim first (beads-eio.3.299). --build-id=none keeps the ELF a
# function of its sources.
args += ['-DPSX_PYTHON=%s' % sys.executable,
'-DCMAKE_EXE_LINKER_FLAGS=-Wl,--build-id=none',
'-DCMAKE_C_COMPILER_LAUNCHER=', '-DCMAKE_CXX_COMPILER_LAUNCHER=']
run([ctx.tc.cmake, '-S', ctx.root, '-B', ctx.runtime_build, '-G', ctx.tc.generator] + args,
'runtime configure', env=ctx.tc.env)
cache = cmake_cache(ctx.runtime_build)
expected = dict(owned, PSX_PUBLIC_RELEASE='ON' if ctx.channel == 'public' else 'OFF')
for arg in ctx.config.cmake_args + ctx.config.platform_cmake_args[ctx.platform]:
match = re.match(r'^-D([A-Za-z0-9_]+)(?::[A-Z]+)?=(.*)$', arg)
if match:
expected.setdefault(match.group(1), match.group(2))
for key, want in expected.items():
got = cache.get(key)
if key == 'PSXRECOMP_ROOT':
same = got is not None and Path(got).resolve() == FRAMEWORK
else:
same = (got == want or
(want.upper() in ('ON', 'OFF') and got is not None and
cmake_bool(got) == cmake_bool(want)))
if not same:
fail('configured %s=%r but CMakeCache.txt has %r (the title CMake overrode it?)'
% (key, want, got))
if cache.get('PSX_RECOMP_UI', 'ON').upper() == 'OFF':
fail('PSX_RECOMP_UI=OFF: a player release needs the recomp-ui launcher')
run([ctx.tc.cmake, '--build', ctx.runtime_build, '--target', ctx.config.runtime_target,
'-j', ctx.jobs], 'runtime build', env=ctx.tc.env)
ctx.cache = cmake_cache(ctx.runtime_build)
def locate_executable(ctx):
marker = ctx.runtime_build / ('psxrecomp_exe_name-%s.txt' % ctx.config.runtime_target)
candidates = []
if marker.is_file():
candidates.append(read_text(marker).strip())
candidates.append(ctx.config.runtime_target)
for name in candidates:
for directory in (ctx.runtime_build, ctx.runtime_build / 'Release'):
path = directory / (name + ctx.tc.exe_suffix)
if path.is_file():
return path
fail('built executable not found under %s (marker %s)' % (ctx.runtime_build, marker))
def check_build_outputs(ctx):
exe = locate_executable(ctx)
ctx.built_exe = exe
ctx.exe_dir = exe.parent
stamp = ctx.exe_dir / 'psx_game_version.txt'
if not stamp.is_file():
fail('missing %s (rebuild with a current runtime.cmake)' % stamp)
built = read_text(stamp).strip()
if built != ctx.version:
fail('binary version stamp %r != %s %r' % (built, ctx.config.version_file.name, ctx.version))
if ctx.config.binary_identity:
token = ctx.config.binary_identity.format(
version=ctx.version, commit=ctx.prov['source_commit'],
commit12=ctx.prov['source_commit'][:12])
data = exe.read_bytes()
if token.encode('utf-8') not in data:
fail('executable lacks build identity %r: stale binary or wrong source commit' % token)
log('build identity present: %s' % token)
if ctx.config.aot_mode == 'static':
ctx.static_audit = check_static_audit(ctx)
run_hooks(ctx, 'post_build', exe=str(exe))
def check_static_audit(ctx):
"""The linked static AOT units are exactly the audited original-disc output."""
config = ctx.config
profile = json.loads(read_text(config.aot_profile))
output = profile.get('static_output')
if not output:
fail('%s: AOT mode "static" needs a profile static_output' % config.aot_profile)
out_dir = (config.game_toml.parent / output).parent
receipt_path = out_dir / 'AOT_STATIC_AUDIT.json'
if not receipt_path.is_file():
fail('missing %s; the static AOT step did not publish' % receipt_path)
audit = json.loads(read_text(receipt_path))
if not audit.get('all_pairs_valid') or not audit.get('all_guards_match_known_input_bytes'):
fail('original-disc static AOT audit failed (%s)' % receipt_path)
expected = profile.get('expected_records')
if expected is not None and audit.get('recipe_count') != expected:
fail('static AOT recipe_count %s != profile expected_records %s'
% (audit.get('recipe_count'), expected))
inputs = audit.get('generation_inputs', {})
if inputs.get('game_toml_sha256') != sha256(config.game_toml):
fail('%s changed since static AOT generation' % config.game_toml.name)
if inputs.get('profile_sha256') != sha256(config.aot_profile):
fail('AOT profile changed since static AOT generation')
files = audit.get('files') or {}
if not files:
fail('static AOT receipt lists no files')
for name, digest in files.items():
if sha256(out_dir / name) != digest:
fail('static AOT unit %s differs from its audit receipt' % name)
ninja = ctx.runtime_build / 'build.ninja'
if ninja.is_file():
exe = ctx.built_exe.name
links = [line for line in read_text(ninja).splitlines()
if line.startswith('build ') and 'EXECUTABLE_LINKER' in line and exe in line.split(':')[0]]
if not links:
fail('no link rule for %s in build.ninja' % exe)
for name in files:
if name.endswith('.c') and not any(name in line for line in links):
fail('static AOT unit %s is not linked into %s' % (name, exe))
log('static AOT receipt verified: %d recipes, %d files' % (audit.get('recipe_count', 0), len(files)))
return dict(recipe_count=audit.get('recipe_count'),
published_variants=audit.get('published_variants'),
full_static_coverage_proven=audit.get('full_static_coverage_proven'),
profile_sha256=audit.get('profile_sha256'),
generation_inputs_sha256=audit.get('generation_inputs_sha256'))
# --------------------------------------------------------------------------
# Staging
# --------------------------------------------------------------------------
def copy(src, dst):
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def stage_payload(ctx):
config, stage, exe_dir = ctx.config, ctx.stage, ctx.exe_dir
if stage.exists():
shutil.rmtree(stage)
stage.mkdir(parents=True)
exe_name = (config.exe_name or ctx.built_exe.stem) + ctx.tc.exe_suffix
ctx.exe_name = exe_name
copy(ctx.built_exe, stage / exe_name)
if ctx.platform == 'linux':
os.chmod(stage / exe_name, 0o755)
copy(ctx.exe_dir / 'psx_game_version.txt', stage / 'psx_game_version.txt')
for tree in ('assets/fonts', 'assets/img'):
if not (exe_dir / tree).is_dir():
fail('%s missing: the recomp-ui launcher assets were not staged by the build'
% (exe_dir / tree))
shutil.copytree(exe_dir / 'assets', stage / 'assets')
if config.launcher_boxart is not None:
staged = stage / 'assets/img/boxart.tga'
if not staged.is_file() or sha256(staged) != sha256(config.launcher_boxart):
fail('staged launcher boxart does not match %s' % config.launcher_boxart)
bios, notice = exe_dir / 'bios/openbios.bin', exe_dir / 'bios/OpenBIOS.LICENSE'
if not bios.is_file() or bios.stat().st_size != OPENBIOS_SIZE or not notice.is_file():
fail('the build did not stage OpenBIOS (%d bytes) and its MIT notice' % OPENBIOS_SIZE)
copy(bios, stage / 'bios/openbios.bin')
copy(notice, stage / 'bios/OpenBIOS.LICENSE')
copy(config.player_game_toml, stage / 'game.toml')
options = config.root / 'game_options.toml'
if options.is_file():
copy(options, stage / 'game_options.toml')
game = tomllib.loads(read_text(stage / 'game.toml'))
ctx.game_id = game.get('game', {}).get('id')
if not ctx.game_id:
fail('[game] id missing from %s' % config.player_game_toml)
# Mod catalog: the build's own catalog list is the invariant.
run([sys.executable, TOOLS / 'release_stage.py', 'stage-mods', '--build-path', exe_dir,
'--stage', stage, '--runtime-target', config.runtime_target], 'mod catalog staging')
if ctx.channel == 'public':
run([sys.executable, TOOLS / 'mod_channel_filter.py', stage / 'mods/bundled'],
'developer-channel pruning')
ctx.mod_packages = staged_mod_ids(stage)
if not ctx.mod_packages:
fail('staged mod catalog is empty')
check_diagnostics(ctx, stage)
for rel in config.build_files:
if not (exe_dir / rel).is_file():
fail('build file %s missing beside the executable' % rel)
copy(exe_dir / rel, stage / rel)
if config.diagnostics and config.diagnostics['replay_helper']:
helper = exe_dir / 'replay-diagnostics.py'
if not helper.is_file():
fail('diagnostics replay helper missing beside the executable (%s)' % helper)
copy(helper, stage / 'replay-diagnostics.py')
if config.aot_mode == 'cache':
run([sys.executable, TOOLS / 'aot_overlay_pipeline.py', 'release',
'--profile', config.aot_profile, '--game-toml', config.game_toml,
'--runtime-config', stage / 'game.toml', '--runtime-build-dir', ctx.runtime_build,
'--runtime-target', config.runtime_target, '--recompiler', ctx.recompiler,
'--work-dir', ctx.work / 'aot-cache', '--stage', stage, '--gcc', ctx.tc.gcc,
'--workers', ctx.jobs] + (['--cps'] if config.aot_cps else []),
'original-disc AOT cache', cwd=ctx.root, env=ctx.tc.env)
if not (stage / 'AOT_CACHE_AUDIT.json').is_file():
fail('AOT release staged no AOT_CACHE_AUDIT.json')
if config.ship_toolchain:
command = [sys.executable, TOOLS / 'release_stage.py', 'stage-toolchain', '--stage', stage,
'--recomp-dir', ctx.recompiler_dir, '--recomp-tools', TOOLS,
'--recomp-include', FRAMEWORK / 'runtime/include',
'--dl-cache', ctx.dl_cache, '--platform', TOOLCHAIN_PLATFORM[ctx.platform]]
if ctx.platform == 'windows':
command += ['--mingw-bin', ctx.tc.bin]
run(command, 'overlay toolchain staging', env=ctx.tc.env)
licenses = stage / 'licenses'
licenses.mkdir(exist_ok=True)
for notice_file in sorted((FRAMEWORK / 'runtime/licenses').iterdir()):
if notice_file.is_file():
copy(notice_file, licenses / notice_file.name)
copy(FRAMEWORK / 'LICENSE', licenses / 'psxrecomp-LICENSE')
if (FRAMEWORK / 'THIRD_PARTY_ATTRIBUTION.md').is_file():
copy(FRAMEWORK / 'THIRD_PARTY_ATTRIBUTION.md', licenses / 'psxrecomp-THIRD_PARTY_ATTRIBUTION.md')
ui_root = Path(ctx.cache.get('RECOMP_UI_ROOT') or (config.root / 'recomp-ui'))
if not (ui_root / 'LICENSE').is_file():
fail('recomp-ui LICENSE not found under %s' % ui_root)
copy(ui_root / 'LICENSE', licenses / 'recomp-ui-LICENSE')
for sub in ('fonts', 'img'):
notice_md = ui_root / 'assets/common' / sub / 'NOTICE.md'
if notice_md.is_file():
copy(notice_md, stage / 'assets' / sub / 'NOTICE.md')
for source, rel in config.files:
copy(source, stage / rel)
start_here = read_text(config.start_here)
if '@VERSION@' not in start_here:
fail('%s has no @VERSION@ token; the shipped header must be stamped, not hand-edited'
% config.start_here)
start_here = start_here.replace('@VERSION@', ctx.version)
(stage / 'START_HERE.txt').write_text(start_here, encoding='utf-8', newline='\n')
run_hooks(ctx, 'post_stage', stage=str(stage))
def staged_mod_ids(stage):
bundled = Path(stage) / 'mods/bundled'
ids = []
for manifest in sorted(bundled.glob('*/*/manifest.toml')):
ids.append(tomllib.loads(read_text(manifest)).get('id', manifest.parent.parent.name))
return sorted(set(ids))
def diagnostics_manifests(mods_root):
found = {}
for manifest in sorted(Path(mods_root).glob('*/*/manifest.toml')):
data = tomllib.loads(read_text(manifest))
features = {f.get('id') for f in data.get('feature', [])}
if {'recording', 'osd'} <= features and str(data.get('id', '')).endswith('.diagnostics'):
found.setdefault(data['id'], []).append((manifest, data))
return found
def check_diagnostics(ctx, stage):
"""Diagnostics defaults follow the channel; a diagnostics package the
config does not declare is a config error, not something to skip."""
found = diagnostics_manifests(Path(stage) / 'mods/bundled')
declared = ctx.config.diagnostics
if not declared:
if found:
fail('staged catalog carries %s but the release config has no [diagnostics] table'
% ', '.join(sorted(found)))
return
entries = found.get(declared['package_id'])
if not entries:
fail('staged catalog is missing %s' % declared['package_id'])
want = ctx.channel == 'playtest'
for manifest, data in entries:
if data.get('channel') == 'developer':
fail('%s must stay player-selectable' % manifest)
features = {f.get('id'): f for f in data.get('feature', [])}
for feature in ('recording', 'osd'):
got = features[feature].get('default_enabled')
if got is not want or features[feature].get('channel') == 'developer':
fail('%s: %s default_enabled=%r, a %s package needs %r'
% (manifest, feature, got, ctx.channel, want))
log('Playtest Diagnostics default %s (%s)' % ('on' if want else 'off', ctx.channel))
def payload_gates(stage, plat, exe_name):
"""Content that must never ship, whatever the title config says."""
stage = Path(stage)
present = [name for name in BUILD_KIT if (stage / name).exists()]
if present:
fail('package contains build-kit/user content: %s' % ', '.join(present))
for path in sorted(stage.rglob('*')):
if path.is_symlink():
target = (path.parent / os.readlink(path)).resolve()
if stage.resolve() not in target.parents:
fail('symlink leaving the package: %s' % path.relative_to(stage))
continue
if not path.is_file():
continue
rel = path.relative_to(stage).as_posix()
lower, leaf = rel.lower(), path.name.lower()
suffix = path.suffix.lower()
if suffix in DISC_SUFFIXES:
fail('disc/save data in package: %s' % rel)
if suffix in ('.bin', '.rom') and lower != 'bios/openbios.bin':
fail('binary image in package (only bios/openbios.bin may ship): %s' % rel)
if re.match(r'scph.*\.(bin|rom)', leaf):
fail('retail BIOS image in package: %s' % rel)
if leaf in PERSONAL_NAMES or lower.startswith(('mods/installed/', 'saves/', 'diagnostics/')):
fail('personal/user state in package: %s' % rel)
if suffix in ('.c', '.cpp', '.cc') and not lower.startswith('overlay_toolchain/'):
fail('source code in package: %s' % rel)
if plat == 'linux' and suffix in ('.dll', '.exe'):
fail('Windows binary in a Linux package: %s' % rel)
bios = stage / 'bios/openbios.bin'
if not bios.is_file() or bios.stat().st_size != OPENBIOS_SIZE:
fail('package lacks the bundled OpenBIOS')
if not (stage / exe_name).is_file():
fail('package lacks its executable %s' % exe_name)
def windows_imports(exe, objdump):
text = run([objdump, '-p', exe], 'objdump imports', capture=True)
imports = re.findall(r'DLL Name:\s*(\S+)', text)
if not imports:
fail('objdump found no imports in %s' % exe)
foreign = [d for d in imports if d.lower().removesuffix('.dll') not in SYSTEM_DLLS
and not re.match(r'api-ms-win-[a-z0-9-]+\.dll$', d.lower())]
if foreign:
fail('%s is not self-contained: imports %s' % (Path(exe).name, ', '.join(foreign)))
return imports
def sign_windows(ctx):
if not (os.environ.get('WINDOWS_SIGN_PFX') or os.environ.get('WINDOWS_SIGN_PFX_BASE64')):
log('Windows binaries ship unsigned (no WINDOWS_SIGN_PFX certificate configured)')
ctx.signed = False
return
bash = shutil.which('bash')
if not bash:
fail('a signing certificate is configured but bash is not available to run sign_windows.sh')
run([bash, TOOLS / 'ci/sign_windows.sh', ctx.stage], 'Authenticode signing')
ctx.signed = True
def bind_execution(ctx, binary, output):
sidecar = ctx.built_exe.with_name(ctx.built_exe.stem + '.execution.json')
if not sidecar.is_file():
fail('build published no execution contract %s' % sidecar)
run([sys.executable, TOOLS / 'release_stage.py', 'stage-execution', '--binary', binary,
'--manifest', sidecar, '--output', output], 'execution contract binding')
def write_manifest(ctx, extra):
stage = ctx.stage
manifest = dict(
schema=1, product=ctx.config.name, display_name=ctx.config.display_name,
version=ctx.version, channel=ctx.channel, public_release=ctx.channel == 'public',
platform=ctx.platform, game_id=ctx.game_id, executable=ctx.exe_name,
source_commit=ctx.prov['source_commit'], source_dirty=ctx.prov['source_dirty'],
dirty_paths=ctx.prov['dirty_paths'], framework_commit=ctx.prov['framework_commit'],
recomp_ui_commit=ctx.prov['recomp_ui_commit'], debug_tools=False,
bios_backends=['OpenBIOS'], setup_wizard=cmake_bool(ctx.cache.get('PSX_SETUP_WIZARD', 'OFF')),
playtest_diagnostics_default_enabled=(ctx.channel == 'playtest') if ctx.config.diagnostics else None,
mod_packages=ctx.mod_packages, aot_mode=ctx.config.aot_mode,
overlay_toolchain=ctx.config.ship_toolchain, signed=getattr(ctx, 'signed', False),
packager='psxrecomp/tools/package_title_release.py')
if getattr(ctx, 'static_audit', None):
manifest['static_aot'] = ctx.static_audit
manifest.update(extra)
files = {}
for path in sorted(stage.rglob('*')):
if path.is_file() and path.name != 'RELEASE_MANIFEST.json':
files[path.relative_to(stage).as_posix()] = sha256(path)
manifest['files'] = files
(stage / 'RELEASE_MANIFEST.json').write_text(json.dumps(manifest, indent=2) + '\n',
encoding='utf-8', newline='\n')
return manifest
def write_sha256(artifact):
sidecar = Path(str(artifact) + '.sha256')
sidecar.write_text('%s %s\n' % (sha256(artifact), Path(artifact).name), encoding='ascii',
newline='\n')
return sidecar
def diagnostics_audit(ctx, target):
diag = ctx.config.diagnostics
if not diag:
return
command = [sys.executable, TOOLS / 'ci/audit_playtest_diagnostics_package.py', target,
'--game-id', diag['game_id'], '--diagnostics-id', diag['package_id'],
'--mode', ctx.channel]
if not diag['replay_helper']:
command.append('--no-replay-helper')
run(command, 'Playtest Diagnostics package audit (%s)' % ctx.channel)
def package_windows(ctx):
payload_gates(ctx.stage, 'windows', ctx.exe_name)
imports = windows_imports(ctx.stage / ctx.exe_name, ctx.tc.objdump)
log('Verified self-contained: %d imports, all system DLLs' % len(imports))
sign_windows(ctx)
bind_execution(ctx, ctx.stage / ctx.exe_name,
ctx.stage / (Path(ctx.exe_name).stem + '.execution.json'))
write_manifest(ctx, dict(system_dll_imports=imports))
artifact = ctx.out_dir / artifact_name(ctx.config, ctx.version, ctx.channel, 'windows')
for stale in (artifact, Path(str(artifact) + '.sha256')):
if stale.exists():
stale.unlink()
zip_source = ctx.stage
if ctx.config.zip_folder:
zip_source = ctx.work / 'zip-root'
if zip_source.exists():
shutil.rmtree(zip_source)
zip_source.mkdir(parents=True)
shutil.copytree(ctx.stage, zip_source / ctx.config.zip_folder)
run([sys.executable, TOOLS / 'create_release_zip.py', '--source', zip_source,
'--output', artifact], 'ZIP creation')
diagnostics_audit(ctx, artifact)
write_sha256(artifact)
return artifact
def render_template(path, tokens):
text = read_text(path)
for key, value in tokens.items():
text = text.replace('@%s@' % key, value)
left = sorted(set(re.findall(r'@[A-Z][A-Z0-9_]*@', text)))
if left:
fail('%s has unstamped token(s) %s' % (path, ', '.join(left)))
return text
def package_linux(ctx):
linux = ctx.config.linux
if not linux:
fail('%s has no [linux] table (AppRun, desktop file, icon, payload_dir)' % ctx.config.path)
payload_gates(ctx.stage, 'linux', ctx.exe_name)
diagnostics_audit(ctx, ctx.stage)
bind_execution(ctx, ctx.stage / ctx.exe_name, ctx.stage / (ctx.exe_name + '.execution.json'))
libc = run(['getconf', 'GNU_LIBC_VERSION'], 'glibc version', capture=True).strip()
write_manifest(ctx, dict(linux_build_libc=libc))
meta = ctx.work / 'appimage-meta'
if meta.exists():
shutil.rmtree(meta)
meta.mkdir(parents=True)
tokens = dict(VERSION=ctx.version, APP_NAME=ctx.config.display_name, EXE_NAME=ctx.exe_name,
PAYLOAD_DIR=linux['payload_dir'], ENV_PREFIX=linux['env_prefix'],
ARTIFACT_NAME=ctx.config.name)
app_run = meta / 'AppRun'
app_run.write_text(render_template(linux['app_run'], tokens), encoding='utf-8', newline='\n')
desktop = meta / linux['desktop_file'].name
desktop.write_text(render_template(linux['desktop_file'], tokens), encoding='utf-8', newline='\n')
icon = meta / (desktop.stem + '.png')
convert = shutil.which('magick') or shutil.which('convert')
if linux['icon'].suffix.lower() == '.png':
copy(linux['icon'], icon)
elif convert:
run([convert, linux['icon'], '-resize', '240x240', '-background', 'transparent',
'-gravity', 'center', '-extent', '256x256', icon], 'icon conversion')
else:
fail('ImageMagick is required to convert %s to the AppImage PNG icon' % linux['icon'])
artifact = ctx.out_dir / artifact_name(ctx.config, ctx.version, ctx.channel, 'linux')
for stale in (artifact, Path(str(artifact) + '.sha256')):
if stale.exists() or stale.is_symlink():
stale.unlink()
env = dict(ctx.tc.env, SOURCE_DATE_EPOCH=str(ctx.source_date_epoch),
PSX_RELEASE_STAGE_PYTHON=sys.executable)
command = ['bash', TOOLS / 'package_appimage.sh', '--payload', ctx.stage,
'--exe-name', ctx.exe_name, '--payload-name', linux['payload_dir'],
'--app-run', app_run, '--desktop-file', desktop, '--icon', icon,
'--output', artifact, '--jobs', ctx.jobs]
if linux['prune_libs']:
command.append('--prune-unreferenced-libs')
run(command, 'AppImage assembly', env=env)
write_sha256(artifact)
return artifact
# --------------------------------------------------------------------------
# Verification of a finished artifact (also the `verify` subcommand)
# --------------------------------------------------------------------------
def verify_payload(payload, exe_path, config, channel, plat, version):
payload = Path(payload)
manifest_path = payload / 'RELEASE_MANIFEST.json'
if not manifest_path.is_file():
fail('artifact has no RELEASE_MANIFEST.json')
manifest = json.loads(read_text(manifest_path))
problems = []
if manifest.get('channel') != channel:
problems.append('manifest channel %r != %s' % (manifest.get('channel'), channel))
if manifest.get('version') != version:
problems.append('manifest version %r != %s' % (manifest.get('version'), version))
if read_text(payload / 'psx_game_version.txt').strip() != version:
problems.append('psx_game_version.txt does not say %s' % version)
start = read_text(payload / 'START_HERE.txt')
if version not in start or '@VERSION@' in start:
problems.append('START_HERE.txt is not stamped with %s' % version)
staged = staged_mod_ids(payload)
if staged != manifest.get('mod_packages'):
problems.append('mod catalog %s != manifest %s' % (staged, manifest.get('mod_packages')))
found = diagnostics_manifests(payload / 'mods/bundled')
if config.diagnostics:
want = channel == 'playtest'
entries = found.get(config.diagnostics['package_id'], [])
if not entries:
problems.append('diagnostics package missing')
for manifest_file, data in entries:
features = {f.get('id'): f for f in data.get('feature', [])}
for feature in ('recording', 'osd'):
if features[feature].get('default_enabled') is not want:
problems.append('%s %s default_enabled is not %r' % (manifest_file, feature, want))
elif found:
problems.append('undeclared diagnostics package(s) %s' % sorted(found))
files = manifest.get('files', {})
for rel, digest in files.items():
path = payload / rel
if plat == 'linux' and rel in (manifest.get('executable'),
'%s.execution.json' % manifest.get('executable')):
continue # moved to usr/bin, possibly re-linked; re-bound by its execution contract
if not path.is_file():
problems.append('manifest file missing: %s' % rel)
elif sha256(path) != digest:
problems.append('manifest hash mismatch: %s' % rel)
if problems:
fail('verification failed:\n ' + '\n '.join(problems))
exe_name = manifest['executable']
# Re-run the content gates over exactly what a player receives (the
# payload is a private extraction, so the AppImage's ELF can join it).
if plat == 'linux':
copy(exe_path, payload / exe_name)
payload_gates(payload, plat, exe_name)
return manifest
def verify_artifact(artifact, config, channel, version, objdump=None, scratch=None):
"""Extract into a private directory under `scratch` (default: beside the
artifact; never the system temp drive on Windows) and check everything."""
artifact = Path(artifact)
plat = 'windows' if artifact.name.endswith('.zip') else 'linux'
expected = artifact_name(config, version, channel, plat)
if artifact.name != expected:
fail('artifact name %s != expected %s' % (artifact.name, expected))
sidecar = Path(str(artifact) + '.sha256')
if not sidecar.is_file() or read_text(sidecar).split()[0] != sha256(artifact):
fail('%s missing or does not match' % sidecar.name)
if scratch is None:
scratch = tempfile.gettempdir() if host_platform() == 'linux' else artifact.parent
work = Path(tempfile.mkdtemp(prefix='.psx-release-verify-', dir=str(scratch)))
try:
if plat == 'windows':
with zipfile.ZipFile(artifact) as archive:
for info in archive.infolist():
name = info.filename
if '\\' in name or name.startswith('/') or '..' in PurePosixPath(name).parts:
fail('unsafe/non-portable ZIP entry %r' % name)
archive.extractall(work)
payload = work / config.zip_folder if config.zip_folder else work
if config.zip_folder and sorted(p.name for p in work.iterdir()) != [config.zip_folder]:
fail('ZIP must hold exactly one top-level folder %s' % config.zip_folder)
manifest = verify_payload(payload, None, config, channel, 'windows', version)
exe = payload / manifest['executable']
if objdump:
windows_imports(exe, objdump)
layout = 'zip'
else:
if host_platform() != 'linux':
fail('verify an AppImage on Linux (or through WSL)')
os.chmod(artifact, 0o755)
run([artifact, '--appimage-extract'], 'AppImage extraction', cwd=work, capture=True)
root = work / 'squashfs-root'
linux = config.linux
payload = root / 'usr/share' / linux['payload_dir']
manifest = json.loads(read_text(payload / 'RELEASE_MANIFEST.json'))
exe = root / 'usr/bin' / manifest['executable']
for need, what in ((root / 'AppRun', 'AppRun'), (exe, 'usr/bin executable'),
(payload / 'game.toml', 'payload game.toml')):
if not need.is_file():
fail('AppImage layout: %s missing' % what)
if not os.access(root / 'AppRun', os.X_OK) or not os.access(exe, os.X_OK):
fail('AppImage layout: AppRun/executable not executable')
if exe.read_bytes()[:4] != b'\x7fELF':
fail('AppImage executable is not ELF')
if not list(root.glob('*.desktop')) or not (root / '.DirIcon').exists():
fail('AppImage layout: desktop entry or .DirIcon missing')
if not (root / 'usr/bin/assets').exists():
fail('AppImage layout: usr/bin/assets link missing')
missing = run(['env', '-u', 'LD_LIBRARY_PATH', 'ldd', exe], 'ldd', capture=True)
if 'not found' in missing:
fail('AppImage executable has unresolved libraries:\n' + missing)
verify_payload(payload, exe, config, channel, 'linux', version)
layout = 'appimage'
log('VERIFIED %s: %s %s, %d mod packages, diagnostics %s, OpenBIOS only (%s)'
% (artifact.name, manifest['product'], manifest['version'], len(manifest['mod_packages']),
{True: 'on', False: 'off', None: 'n/a'}[manifest['playtest_diagnostics_default_enabled']],
layout))
return manifest
finally:
shutil.rmtree(work, ignore_errors=True)
# --------------------------------------------------------------------------
# Driver
# --------------------------------------------------------------------------
def to_wsl_path(path):
path = str(Path(path).resolve())
match = re.match(r'^([A-Za-z]):[\\/](.*)$', path)
if not match:
fail('cannot map %s into WSL (only drive paths are supported)' % path)
return '/mnt/%s/%s' % (match.group(1).lower(), match.group(2).replace('\\', '/'))
def run_in_wsl(args, ctx):
"""Run this script's Linux half inside WSL on the same checkout."""
wsl = shutil.which('wsl.exe') or shutil.which('wsl')
if not wsl:
fail('--platform linux on Windows needs WSL (wsl.exe not found)')
argv = ['python3', to_wsl_path(Path(__file__)), 'package', '--channel', args.channel,
'--platform', 'linux', '--root', to_wsl_path(ctx.root),
'--config', to_wsl_path(ctx.config.path), '--jobs', str(ctx.jobs),
'--out-dir', to_wsl_path(ctx.out_dir), '--sources-prepared']
if args.work_dir:
argv += ['--work-dir', to_wsl_path(Path(args.work_dir))]
if args.allow_dirty:
argv.append('--allow-dirty')
env = {'PSX_RELEASE_SOURCE_COMMIT': ctx.prov['source_commit'],
'PSX_RELEASE_SOURCE_DIRTY': '1' if ctx.prov['source_dirty'] else '0',
'PSX_RELEASE_DIRTY_PATHS': json.dumps(ctx.prov['dirty_paths']),
'PSX_RELEASE_FRAMEWORK_COMMIT': ctx.prov['framework_commit'],
'PSX_RELEASE_UI_COMMIT': ctx.prov['recomp_ui_commit'] or '',
'PSX_RELEASE_SOURCE_DATE_EPOCH': str(ctx.prov['source_date_epoch'])}
script = 'set -e; ' + ' '.join('export %s=%s;' % (k, shlex.quote(v)) for k, v in env.items())
script += ' exec ' + ' '.join(shlex.quote(a) for a in argv)
run([wsl, '-e', 'bash', '-c', script], 'Linux packaging in WSL')
def make_context(args, plat):
ctx = Context()
ctx.root = Path(args.root).resolve()
ctx.config = Config(Path(args.config) if Path(args.config).is_absolute()
else ctx.root / args.config, ctx.root)
ctx.channel = args.channel
ctx.platform = plat
ctx.version = ctx.config.version()
ctx.jobs = args.jobs
ctx.out_dir = Path(args.out_dir).resolve() if args.out_dir else ctx.root / 'dist'
ctx.out_dir.mkdir(parents=True, exist_ok=True)
base = Path(args.work_dir).resolve() if args.work_dir else ctx.root / 'build-package'
ctx.work = base / plat
ctx.work.mkdir(parents=True, exist_ok=True)
ctx.runtime_build = ctx.work / 'runtime'
ctx.stage = ctx.work / 'stage'
ctx.dl_cache = base / 'dl-cache'
return ctx
def package(args):
host = host_platform()
platforms = list(PLATFORMS) if args.platform == 'all' else [args.platform or host]
if host == 'linux' and 'windows' in platforms:
fail('Windows packages are built on a Windows host')
artifacts = []
for plat in platforms:
ctx = make_context(args, plat)
ui_root = ctx.root / 'recomp-ui'
ctx.prov = provenance(ctx.root, ui_root)
if ctx.prov['source_dirty'] and not args.allow_dirty:
fail('tracked changes in the release source (%s); commit them, or pass --allow-dirty '
'for a non-publishable test package' % ', '.join(ctx.prov['dirty_paths'][:8]))
log('== %s %s | channel %s | platform %s | source %s%s'
% (ctx.config.name, ctx.version, ctx.channel, plat, ctx.prov['source_commit'][:12],
' (DIRTY)' if ctx.prov['source_dirty'] else ''))
if plat == 'linux' and host == 'windows':
# Prepare once with this host's emitters; Linux compiles the same sources.
ctx.tc = Toolchain('windows', args.toolchain_bin)
ctx.work = ctx.work.parent / 'windows'
ctx.runtime_build = ctx.work / 'runtime'
build_emitters(ctx)
ensure_openbios(ctx)
prepare_sources(ctx)
run_in_wsl(args, ctx)
artifacts.append(ctx.out_dir / artifact_name(ctx.config, ctx.version, ctx.channel, 'linux'))
continue
ctx.tc = Toolchain(plat, args.toolchain_bin)
scratch = None
if plat == 'linux':
if hasattr(os, 'nice'):
try:
os.nice(10) # packaging must not make the machine unusable
except OSError:
pass
# The payload, AppDir and verification extraction need a native
# Linux filesystem (symlinks, exec bits); a /mnt/<drive> DrvFs tree
# cannot hold them. Removed when this platform finishes.
scratch = Path(tempfile.mkdtemp(prefix='psx-title-release.', dir='/tmp'))
fs = run(['stat', '-f', '-c', '%T', scratch], 'stat', capture=True).strip()
if fs in ('9p', 'drvfs') or fs.startswith('ntfs'):
fail('/tmp is not a native Linux filesystem (%s)' % fs)
ctx.stage = scratch / 'stage'
ctx.tc.env['TMPDIR'] = str(scratch)
tempfile.tempdir = str(scratch)
try:
artifacts.append(package_platform(ctx, args))
finally:
if scratch is not None:
tempfile.tempdir = None
shutil.rmtree(scratch, ignore_errors=True)
for artifact in artifacts:
if not artifact.is_file():
fail('expected artifact was not produced: %s' % artifact)
log('PACKAGED %s' % artifact)
return 0
def package_platform(ctx, args):
plat = ctx.platform
epoch = os.environ.get('SOURCE_DATE_EPOCH')
ctx.source_date_epoch = (int(epoch) if epoch and epoch.isdigit()
else ctx.prov['source_date_epoch'])
needs_emitters = (not args.sources_prepared or ctx.config.ship_toolchain or
ctx.config.aot_mode == 'cache')
if needs_emitters:
build_emitters(ctx)
ensure_openbios(ctx)
if not args.sources_prepared:
prepare_sources(ctx)
configure_and_build(ctx)
check_build_outputs(ctx)
stage_payload(ctx)
artifact = package_windows(ctx) if plat == 'windows' else package_linux(ctx)
verify_artifact(artifact, ctx.config, ctx.channel, ctx.version,
objdump=ctx.tc.objdump if plat == 'windows' else None,
scratch=ctx.work if plat == 'windows' else None)
return artifact
def verify(args):
root = Path(args.root).resolve()
config = Config(Path(args.config) if Path(args.config).is_absolute() else root / args.config, root)
objdump = None
if os.name == 'nt':
candidate = Path(args.toolchain_bin) / 'objdump.exe'
objdump = str(candidate) if candidate.is_file() else shutil.which('objdump')
for artifact in args.artifacts:
verify_artifact(artifact, config, args.channel, config.version(), objdump=objdump)
return 0
def check_diagnostics_dir(args):
"""Every *.diagnostics package in a staged mods/bundled follows the channel.
For packagers that have no release config (package_game_release.sh)."""
found = diagnostics_manifests(args.bundled)
want = args.channel == 'playtest'
for package, entries in sorted(found.items()):
for manifest, data in entries:
features = {f.get('id'): f for f in data.get('feature', [])}
for feature in ('recording', 'osd'):
got = features[feature].get('default_enabled')
if got is not want or features[feature].get('channel') == 'developer':
fail('%s: %s default_enabled=%r, a %s package needs %r'
% (manifest, feature, got, args.channel, want))
log('%s: Playtest Diagnostics default %s (%s)' % (package, 'on' if want else 'off',
args.channel))
if not found:
log('no Playtest Diagnostics package in %s' % args.bundled)
return 0
def parse_args(argv):
parser = argparse.ArgumentParser(
prog='package_title_release.py', description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest='command')
def common(p):
p.add_argument('--channel', required=True, choices=CHANNELS,
help='REQUIRED, no default: public or playtest')
p.add_argument('--config', default='packaging/release.toml',
help='title release config, relative to --root')
p.add_argument('--root', default='.', help='title repository root (default: cwd)')
p.add_argument('--toolchain-bin', default=r'C:\msys64\mingw64\bin',
help='Windows: MinGW-w64 bin with cmake/ninja/gcc/objdump')
pkg = sub.add_parser('package', help='build, stage, package and verify (default)')
common(pkg)
pkg.add_argument('--platform', choices=PLATFORMS + ('all',),
help='default: this host; on Windows "linux"/"all" use WSL')
cores = os.cpu_count() or 4
pkg.add_argument('--jobs', type=int, default=max(1, min(6, cores - 2)),
help='parallel jobs (default min(6, cores-2))')
pkg.add_argument('--out-dir', help='artifact directory (default <root>/dist)')
pkg.add_argument('--work-dir', help='build/stage directory (default <root>/build-package)')
pkg.add_argument('--allow-dirty', action='store_true',
help='package uncommitted tracked changes (recorded; not for publication)')
pkg.add_argument('--sources-prepared', action='store_true', help=argparse.SUPPRESS)
ver = sub.add_parser('verify', help='extract and verify finished artifacts')
common(ver)
ver.add_argument('artifacts', nargs='+')
chk = sub.add_parser('check-diagnostics',
help='check a staged mods/bundled against the channel (no config)')
chk.add_argument('--channel', required=True, choices=CHANNELS)
chk.add_argument('bundled', type=Path)
if not argv or argv[0] not in ('package', 'verify', 'check-diagnostics', '-h', '--help'):
argv = ['package'] + list(argv)
args = parser.parse_args(argv)
if args.command == 'package' and args.jobs < 1:
parser.error('--jobs must be positive')
return args
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
try:
args = parse_args(argv)
except SystemExit as exit_:
if exit_.code not in (0, None):
print('\nPACKAGING FAILED: invalid arguments (--channel public|playtest is required)',
file=sys.stderr, flush=True)
raise
try:
if args.command == 'check-diagnostics':
return check_diagnostics_dir(args)
return package(args) if args.command == 'package' else verify(args)
except PackagingError as error:
print('\nPACKAGING FAILED: %s' % error, file=sys.stderr, flush=True)
return 1
except KeyboardInterrupt:
print('\nPACKAGING FAILED: interrupted', file=sys.stderr, flush=True)
return 130
except Exception as error: # an unexpected bug is still a loud failure
import traceback
traceback.print_exc()
print('\nPACKAGING FAILED: %s: %s' % (type(error).__name__, error), file=sys.stderr, flush=True)
return 1
if __name__ == '__main__':
sys.exit(main())