Add one shared title release command with a required channel

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthew Stanley
2026-10-19 20:46:47 -07:00
parent 449c31e502
commit 56a6cfe782
16 changed files with 2321 additions and 19 deletions
+6 -3
View File
@@ -44,9 +44,12 @@ default them on.
- The source manifest carries the production default:
`default_enabled = false` on both the `recording` and `osd` features.
- `PSX_PUBLIC_RELEASE` (CMake option, default `OFF`) is the one build-level
notion of a public release. Release packagers configure with
`-DPSX_PUBLIC_RELEASE=ON`; nothing else should.
- `PSX_PUBLIC_RELEASE` is the one build-level notion of a public release. It
is derived from `PSX_RELEASE_CHANNEL` (`public` => ON; `playtest` or unset
=> OFF; `runtime/psx_release_channel.cmake`). Releases are cut only by
`tools/package_title_release.py --channel public|playtest`
([RELEASING_A_TITLE.md](RELEASING_A_TITLE.md)); `-DPSX_PUBLIC_RELEASE=ON`
without a channel fails configure.
- After the framework stages the catalog into `<exe-dir>/mods/bundled`,
`runtime/psx_playtest_diagnostics_defaults.cmake` edits only the staged copy:
with `PSX_PUBLIC_RELEASE=OFF` it sets both features to `default_enabled =
+197
View File
@@ -0,0 +1,197 @@
# Releasing a title
One framework command cuts every player release, for every title, on Windows
and Linux. A title keeps only its release config, `packaging/release.toml`.
Release fixes land here once instead of in a forked packager per title.
## Commands
From the title repository root, with submodules checked out at their pins:
```powershell
# Windows host: Windows ZIP, Linux AppImage (through WSL), or both
psxrecomp/tools/package_title_release.ps1 --channel public --platform all
psxrecomp/tools/package_title_release.ps1 --channel playtest --platform all
```
```sh
# Native Linux host: the AppImage
psxrecomp/tools/package_title_release.sh --channel public
psxrecomp/tools/package_title_release.sh --channel playtest
```
`--channel` is required and has no default. Without it the command fails at
once with `PACKAGING FAILED`. Artifacts land in `dist/`:
| Channel | Diagnostics default | Windows | Linux |
|---|---|---|---|
| `public` | off | `<Name>-v<ver>-windows-x64.zip` | `<Name>-v<ver>-linux-x86_64.AppImage` |
| `playtest` | on | `<Name>-v<ver>-playtest-windows-x64.zip` | `<Name>-v<ver>-playtest-linux-x86_64.AppImage` |
Each artifact has a `.sha256` file beside it. `RELEASE_MANIFEST.json` inside
the package records the channel, version, title/framework/recomp-ui commits,
mod list, imports or glibc, and a hash for every file. The command never tags,
pushes or uploads.
Other options: `--jobs N` (default `min(6, cores-2)`), `--out-dir`,
`--work-dir` (default `build-package/<platform>`), `--toolchain-bin`
(Windows MinGW, default `C:\msys64\mingw64\bin`), and `--allow-dirty`.
Without `--allow-dirty`, uncommitted tracked changes, including a submodule
moved off its pin, are refused. With it they are recorded in the manifest, so
that package must not be published.
`verify --channel C ARTIFACT...` extracts finished artifacts and re-runs every
check. The command already does this after each package.
## What the command does
Any failure ends with `PACKAGING FAILED: <reason>` and a non-zero exit code.
No step warns and then continues.
1. **Provenance.** Reads the title, framework and recomp-ui commits and checks
for tracked changes.
2. **Emitters.** Builds `psxrecomp-game` and `psxrecomp-bios` before any slow
step. The overlay toolchain needs both.
3. **Sources.** Regenerates the recompiled OpenBIOS if it is missing, then
regenerates the game C from the disc. For AOT mode `static`, it also builds
the static AOT units from the original disc. On Windows, `--platform linux`
or `all` prepares the sources once there. The WSL build then compiles those
same audited sources.
4. **Build.** Release, `PSX_DEBUG_TOOLS=OFF`, OpenBIOS-only backend
(`PSXRECOMP_BIOS_STEMS=OpenBIOS`), `PSX_RELEASE_CHANNEL=<channel>`, and
`PSX_GAME_VERSION` from the title's version file. Every flag the command
owns is read back from `CMakeCache.txt`. A title's `cmake_args` may not set
any of them.
5. **Stage.** Stages these items:
- the executable and its version stamp
- launcher assets
- OpenBIOS and its MIT notice
- `game.toml` and `game_options.toml`
- the mod catalog, checked against the build's own catalog list.
Developer-channel features are removed from public packages.
- for AOT mode `cache`, the original-disc AOT cache with
`AOT_CACHE_AUDIT.json`
- the overlay toolchain, if the title ships it
- framework, recomp-ui and third-party notices
- the title's files
- `START_HERE.txt` with `@VERSION@` filled in. A template without the
token is an error.
- the execution contract
6. **Gates.** Refuses the package if it contains any of these: disc images,
retail BIOS images, `.bin` files other than `bios/openbios.bin`, saves,
memory cards, save states, personal settings or mod state, generated or
other C sources outside `overlay_toolchain/`, or build-kit trees. It also
checks that the Playtest Diagnostics defaults match the channel, and runs
`tools/ci/audit_playtest_diagnostics_package.py --mode <channel>`. On
Windows the executable must import only system DLLs. For AOT mode `static`,
every audited unit must match its receipt and be linked into the executable.
A diagnostics package that the config does not declare is an error.
7. **Package and verify.** Writes a Windows ZIP with portable `/` names, or
builds a Linux AppImage with `tools/package_appimage.sh`. The AppImage uses
fixed linuxdeploy `1-alpha-20251107-1` and appimagetool `1.9.1`, checked by
SHA-256. The command writes the `.sha256` file, then extracts the artifact
and verifies it.
Linux needs `cmake`, `ninja` (or make), `gcc`, Python 3.11+, ImageMagick,
`curl`, `file` and the SDL3/OpenGL development packages. On a Windows host,
the Linux half runs inside the default WSL distribution, using the same
checkout through `/mnt/<drive>`. The build tree stays on that drive. Only the
payload, AppDir and verification extraction use `/tmp`, and they are removed
afterwards.
## The backstop: no package without a channel
`runtime/psx_release_channel.cmake` makes `PSX_RELEASE_CHANNEL`
(`public` | `playtest`) the only release input. `PSX_PUBLIC_RELEASE` is now
derived from it. Configure fails in these cases:
- a channel other than `public` or `playtest`
- `-DPSX_PUBLIC_RELEASE=ON` without a channel (the old way to ask for a public
build)
- a `PSX_PUBLIC_RELEASE` value that contradicts the channel
A tree configured with no channel is a dev build. The packager configures its
own tree with the channel, then reads it back before staging.
## `packaging/release.toml`
Unknown keys are errors, so a typo cannot silently drop a step.
```toml
schema = 1
[product]
name = "JerseyDevilRecomp" # artifact name prefix (and AppImage data dir token)
display_name = "Jersey Devil Recompiled"
exe_name = "JerseyDevil_Recompiled" # staged executable; default = CMake OUTPUT_NAME
version_file = "packaging/release/VERSION" # raw text = PSX_GAME_VERSION and @VERSION@
# game_toml = "game.toml" # codegen config (default)
# player_game_toml = "game.toml" # shipped config (default = game_toml)
# runtime_target = "psx-runtime"
# zip_folder = "MediEvilIIRecomp" # optional single top-level ZIP folder
launcher_boxart = "launcher_assets/img/boxart.tga" # staged copy must match
[build]
cmake_args = ["-DPSX_SDL_BACKEND=SDL3", "-DPSX_PGXP_VARIANT=OFF"]
# windows_cmake_args / linux_cmake_args: per-platform extras
# regenerate = true # psxrecomp-game --config game.toml first
# binary_identity = "{version}+{commit12}" # bytes the executable must contain
[aot]
mode = "cache" # cache: ship the original-disc AOT cache
# static: compile audited units into the exe
# none
profile = "aot/overlays.json"
# cps = true # default: true for static, false for cache
[overlay_toolchain]
ship = true # bundle the runtime overlay compiler
[diagnostics] # required if the catalog has a *.diagnostics package
game_id = "SCUS-94907"
package_id = "jerseydevil.diagnostics"
# replay_helper = false # title records with its own tool (no replay-diagnostics.py)
[payload]
start_here = "packaging/release/START_HERE.txt" # must contain @VERSION@
files = ["README.md", { from = "packaging/release/input.ini", to = "input.ini" }]
# build_files = ["..."] # extra files from beside the built executable
[linux]
app_run = "packaging/linux/AppRun" # @VERSION@ @APP_NAME@ @EXE_NAME@ @PAYLOAD_DIR@
# @ENV_PREFIX@ @ARTIFACT_NAME@ are filled in
desktop_file = "packaging/linux/io.github.mstan.JerseyDevilRecomp.desktop"
icon = "launcher_assets/img/boxart.tga" # PNG, or converted with ImageMagick
payload_dir = "jerseydevilrecomp" # usr/share/<payload_dir>
env_prefix = "JERSEYDEVIL_RECOMP"
# prune_unreferenced_libs = true # drop usr/lib entries the exe does not load
# Optional title steps, run at prepare | post_build | post_stage. Tokens:
# {python} {root} {framework} {channel} {platform} {version} {build_dir}
# {work_dir}, plus {exe} (post_build) and {stage} (post_stage). A non-zero
# exit fails the release.
# [[hooks.post_stage]]
# command = ["{python}", "tools/check_my_payload.py", "{stage}"]
# platforms = ["windows", "linux"]
```
`aot.mode = "static"` checks `generated/AOT_STATIC_AUDIT.json` in these ways:
every pair is valid, the recipe count matches `expected_records`, the
`game.toml` and profile hashes match, each unit's hash matches its receipt,
and each unit appears in the executable's link line.
## Migrating a title
Rules for every migrated title:
- Delete the forked packagers (`tools/package_release.ps1`,
`tools/package_appimage.sh`, `tools/package_release.py`, prepare scripts,
`scripts/package_setup_release.sh`) and their tests.
- Write `packaging/release.toml`.
- Put `@VERSION@` in `START_HERE.txt`.
- Remove any title CMake that forces `PSX_PUBLIC_RELEASE`.
- Pin the latest psxrecomp and recomp-ui.
Prove the migration with both channels on both platforms, then verify the
artifacts. Titles migrated so far: Jersey Devil and MediEvil II. The backlog
is tracked in bead `beads-eio.3.369`.
+5
View File
@@ -1,5 +1,10 @@
# Bundled releases
> Local title releases (Windows ZIP + Linux AppImage, public or playtest) are
> cut with one framework command, `tools/package_title_release.py`, from a
> title's `packaging/release.toml`: see [../RELEASING_A_TITLE.md](../RELEASING_A_TITLE.md).
> This page describes the CI template built on `package_game_release.sh`.
Public CI builds and ships the **compiled game**. The title commits its
recompiled game C under `generated/`; CI links it, packages the executable with
its runtime data, and attaches one zip per platform. Players download, point
+8
View File
@@ -65,6 +65,13 @@ on:
required: true
type: boolean
default: true
channel:
description: 'Release channel: public (diagnostics off) or playtest (diagnostics on, -playtest names)'
required: true
type: choice
options:
- public
- playtest
reuse_cached_emitters:
description: 'Skip emitter rebuild when build-recompiler cache hits (psxrecomp SHA)'
required: false
@@ -502,6 +509,7 @@ jobs:
-DPSXRECOMP_ALLOW_NO_BIOS=OFF \
-DPSX_SETUP_WIZARD=ON \
-DPSX_GAME_VERSION="${RELEASE_VERSION}" \
-DPSX_RELEASE_CHANNEL="${{ github.event_name == 'push' && 'public' || github.event.inputs.channel }}" \
"${EXTRA[@]}" 2>&1 | tee build-ci-configure.log
# Assert the configure took the FULL path, not the setup-host one.
if ! grep -q 'linking generated game C (full runtime)' build-ci-configure.log; then
+9
View File
@@ -1214,6 +1214,7 @@ if(BUILD_TESTING)
aligned_lzss_banks
bpe_container
audit_playtest_diagnostics_package
package_title_release
bundled_release_layout
collect_game_misses
cursor_writers
@@ -1244,6 +1245,14 @@ if(BUILD_TESTING)
${CMAKE_CURRENT_SOURCE_DIR}/../tools/tests/test_playtest_diagnostics_defaults.py
--cmake ${CMAKE_COMMAND}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
# Configures throwaway projects against runtime/psx_release_channel.cmake:
# a release-package build must name its channel.
add_test(
NAME release_channel_cmake
COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/../tools/tests/test_release_channel_cmake.py
--cmake ${CMAKE_COMMAND}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..)
add_test(
NAME compile_overlays_static_split
COMMAND ${Python3_EXECUTABLE}
+83
View File
@@ -0,0 +1,83 @@
include_guard(GLOBAL)
# Release channel: the one input that says what a release-package build is.
#
# PSX_RELEASE_CHANNEL=public public production release
# PSX_RELEASE_CHANNEL=playtest non-public playtest / pre-release package
# unset (empty) a dev or test build; never packaged
#
# tools/package_title_release.py is the only supported way to cut a release.
# It requires --channel public|playtest (no default), configures with
# -DPSX_RELEASE_CHANNEL=<channel>, and refuses to stage a build tree whose
# channel is empty or different. PSX_PUBLIC_RELEASE is DERIVED from the
# channel here (public => ON); it is no longer an input. Before this module,
# a packaging configure that forgot -DPSX_PUBLIC_RELEASE=ON silently produced
# a playtest build (diagnostics on) under a public name.
#
# Backstop rules (FATAL_ERROR):
# * a channel other than public/playtest;
# * -DPSX_PUBLIC_RELEASE=ON without a channel -- the pre-channel way to ask
# for a public package; name the channel instead;
# * a PSX_PUBLIC_RELEASE value that contradicts the channel.
# An explicit or cached PSX_PUBLIC_RELEASE=OFF without a channel is the old
# option's default (every dev tree configured before this module has it) and
# stays a dev build.
set(PSX_RELEASE_CHANNEL "" CACHE STRING
"Release package channel: public or playtest (empty = dev build, not packageable)")
set_property(CACHE PSX_RELEASE_CHANNEL PROPERTY STRINGS ";public;playtest")
if(NOT PSX_RELEASE_CHANNEL STREQUAL "" AND
NOT PSX_RELEASE_CHANNEL MATCHES "^(public|playtest)$")
message(FATAL_ERROR
"PSX_RELEASE_CHANNEL must be 'public' or 'playtest' (got '${PSX_RELEASE_CHANNEL}'). "
"Cut releases with psxrecomp/tools/package_title_release.py --channel public|playtest.")
endif()
if(DEFINED CACHE{PSX_PUBLIC_RELEASE})
get_property(_psx_public_type CACHE PSX_PUBLIC_RELEASE PROPERTY TYPE)
if(NOT _psx_public_type STREQUAL "INTERNAL")
# Set by the caller (-D) or left by a configure that predates channels.
if(PSX_PUBLIC_RELEASE AND PSX_RELEASE_CHANNEL STREQUAL "")
message(FATAL_ERROR
"PSX_PUBLIC_RELEASE=ON without a release channel. A packaging build must "
"name its channel: -DPSX_RELEASE_CHANNEL=public|playtest (PSX_PUBLIC_RELEASE "
"is derived from it). Releases are cut with "
"psxrecomp/tools/package_title_release.py --channel public|playtest. "
"To reuse this tree as a dev build, reconfigure with -UPSX_PUBLIC_RELEASE.")
endif()
if((PSX_PUBLIC_RELEASE AND PSX_RELEASE_CHANNEL STREQUAL "playtest") OR
(NOT PSX_PUBLIC_RELEASE AND PSX_RELEASE_CHANNEL STREQUAL "public"))
message(FATAL_ERROR
"PSX_PUBLIC_RELEASE=${PSX_PUBLIC_RELEASE} contradicts "
"PSX_RELEASE_CHANNEL=${PSX_RELEASE_CHANNEL}. Pass only the channel "
"(reconfigure with -UPSX_PUBLIC_RELEASE).")
endif()
endif()
unset(_psx_public_type)
endif()
if(PSX_RELEASE_CHANNEL STREQUAL "public")
set(_psx_public_release ON)
else()
set(_psx_public_release OFF)
endif()
set(PSX_PUBLIC_RELEASE ${_psx_public_release} CACHE INTERNAL
"Derived from PSX_RELEASE_CHANNEL (public => ON); not an input")
unset(_psx_public_release)
if(PSX_RELEASE_CHANNEL STREQUAL "")
message(STATUS "psxrecomp: release channel: none (dev build; not packageable)")
else()
message(STATUS "psxrecomp: release channel: ${PSX_RELEASE_CHANNEL} "
"(PSX_PUBLIC_RELEASE=${PSX_PUBLIC_RELEASE})")
endif()
# Optional exact source commit for build identities. The packager passes the
# title's HEAD; a tree configured from WSL cannot always ask git itself (a
# Windows worktree's .git names an F:/ path). Empty = the title decides.
set(PSX_SOURCE_COMMIT "" CACHE STRING
"Exact title source commit for build identities (set by the release packager)")
string(LENGTH "${PSX_SOURCE_COMMIT}" _psx_source_commit_length)
if(NOT PSX_SOURCE_COMMIT STREQUAL "" AND
(NOT PSX_SOURCE_COMMIT MATCHES "^[0-9a-f]+$" OR NOT _psx_source_commit_length EQUAL 40))
message(FATAL_ERROR "PSX_SOURCE_COMMIT must be a full 40-hex commit (got '${PSX_SOURCE_COMMIT}')")
endif()
unset(_psx_source_commit_length)
+6 -5
View File
@@ -122,11 +122,12 @@ endif()
# Dev-only: the lobby browser lists every version of this title instead of
# pinning to our exact game_version. Joining still requires an exact match.
option(PSX_LOBBY_DEV_LIST "Lobby browser ignores game_version (dev testing only)" OFF)
# The one build-level notion of a public production release. Release packagers
# set it ON; dev trees and playtest/pre-release packages leave it OFF. Today it
# selects Playtest Diagnostics defaults (runtime/playtest_diagnostics.cmake):
# OFF stages them default-on, ON keeps the source manifests' default-off.
option(PSX_PUBLIC_RELEASE "Public production release (Playtest Diagnostics default off)" OFF)
# Release channel (public | playtest | empty = dev) and the PSX_PUBLIC_RELEASE
# it derives: the one build-level notion of a public production release. Today
# PSX_PUBLIC_RELEASE selects Playtest Diagnostics defaults
# (runtime/playtest_diagnostics.cmake): OFF stages them default-on, ON keeps
# the source manifests' default-off. See runtime/psx_release_channel.cmake.
include("${CMAKE_CURRENT_LIST_DIR}/psx_release_channel.cmake")
set(PSX_SDL_BACKEND "SDL3" CACHE STRING "SDL backend (SDL3 or SDL2)")
set_property(CACHE PSX_SDL_BACKEND PROPERTY STRINGS SDL3 SDL2)
string(TOUPPER "${PSX_SDL_BACKEND}" _psx_sdl_backend)
+67 -8
View File
@@ -1,7 +1,13 @@
#!/usr/bin/env python3
"""Audit a flat game/setup ZIP without extracting or launching its contents."""
"""Audit a flat game/setup ZIP (or its staged directory) without launching it.
The argument is a release ZIP or a staged payload directory. A directory is
read through the same checks, so a Linux payload is audited before it is
wrapped into an AppImage (an AppImage has no ZIP to audit).
"""
import argparse
from pathlib import PurePosixPath
import os
from pathlib import Path, PurePosixPath
import re
import sys
import zipfile
@@ -28,11 +34,56 @@ PERSONAL_NAMES = {
}
def audit(path, game_id, diagnostics_id, public):
class _DirEntry:
def __init__(self, root, name):
self.filename = name
self._path = os.path.join(root, *name.split("/"))
self.file_size = os.path.getsize(self._path)
def is_dir(self):
return False
class _DirArchive:
"""The read-only subset of zipfile.ZipFile the audit uses, over a directory."""
def __init__(self, root):
self._root = str(root)
self._entries = []
for base, dirs, files in os.walk(self._root):
dirs.sort()
for leaf in sorted(files):
rel = os.path.relpath(os.path.join(base, leaf), self._root)
self._entries.append(_DirEntry(self._root, rel.replace(os.sep, "/")))
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def infolist(self):
return list(self._entries)
def read(self, entry):
with open(entry._path, "rb") as stream:
return stream.read()
def open(self, entry):
return open(entry._path, "rb")
def _open_package(path):
if os.path.isdir(path):
return _DirArchive(path)
return zipfile.ZipFile(path)
def audit(path, game_id, diagnostics_id, public, replay_helper=True):
"""public=True: a public production release (Playtest Diagnostics ship off).
public=False: a playtest/pre-release package (they ship on). Either way the
source manifests keep the production default, off."""
with zipfile.ZipFile(path) as archive:
with _open_package(path) as archive:
files = {}
for entry in archive.infolist():
name = entry.filename.replace("\\", "/")
@@ -79,9 +130,13 @@ def audit(path, game_id, diagnostics_id, public):
recipe = tomllib.loads(read("game.toml").decode("utf-8-sig"))
if recipe.get("game", {}).get("id") != game_id:
raise ValueError("game.toml game ID does not match " + game_id)
read("replay-diagnostics.py")
# Titles on psxrecomp_add_playtest_diagnostics ship the shared replay
# launcher beside the executable; a title with its own recorder does not.
if replay_helper:
read("replay-diagnostics.py")
if not any(executable(name, entry) for name, entry in files.items()):
raise ValueError("no executable adjacent to replay-diagnostics.py")
raise ValueError("no executable adjacent to replay-diagnostics.py" if replay_helper
else "no executable at the package root")
runtime = []
for name in sorted(files):
@@ -145,15 +200,19 @@ def validate_diagnostics(manifest, name, game_id, diagnostics_id, default_enable
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("zip")
parser.add_argument("zip", help="release ZIP or staged payload directory")
parser.add_argument("--game-id", required=True)
parser.add_argument("--diagnostics-id", required=True)
parser.add_argument("--mode", required=True, choices=("public", "playtest"),
help="public: production release, diagnostics default off; "
"playtest: non-public package, diagnostics default on")
parser.add_argument("--no-replay-helper", action="store_true",
help="the title records with its own diagnostics package and ships "
"no replay-diagnostics.py")
args = parser.parse_args()
try:
count = audit(args.zip, args.game_id, args.diagnostics_id, args.mode == "public")
count = audit(args.zip, args.game_id, args.diagnostics_id, args.mode == "public",
replay_helper=not args.no_replay_helper)
except (ValueError, OSError, zipfile.BadZipFile, KeyError, UnicodeError) as error:
sys.exit("diagnostics package audit failed: " + str(error))
print("Diagnostics package audit passed: {} ({} runtime manifest(s), {} mode)".format(
+21 -2
View File
@@ -5,13 +5,17 @@
# and usr/share/<payload-name>, preserving user settings/cards in writable data.
# Usage: package_appimage.sh --payload DIR --exe-name NAME --payload-name NAME
# --app-run FILE --desktop-file FILE.desktop --icon FILE.png --output FILE.AppImage
# [--framework DIR] [--tool-cache DIR] [--jobs N]
# [--framework DIR] [--tool-cache DIR] [--jobs N] [--prune-unreferenced-libs]
# --prune-unreferenced-libs drops usr/lib entries the executable does not load
# (keeps a host file picker's LD_LIBRARY_PATH clean). Every run fails if the
# deployed executable has an unresolved library.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
FRAMEWORK="$(cd "${SCRIPT_DIR}/.." && pwd)"
PAYLOAD=""; EXE_NAME=""; PAYLOAD_NAME=""; APP_RUN=""; DESKTOP=""; ICON=""; OUTPUT=""
TOOLS="${XDG_CACHE_HOME:-${HOME:?}/.cache}/recomp-appimage-tools"
JOBS=2
PRUNE_LIBS=0
while [[ $# -gt 0 ]]; do
case "$1" in
--payload) PAYLOAD="${2:?}"; shift 2;;
@@ -24,7 +28,8 @@ while [[ $# -gt 0 ]]; do
--framework) FRAMEWORK="${2:?}"; shift 2;;
--tool-cache) TOOLS="${2:?}"; shift 2;;
--jobs) JOBS="${2:?}"; shift 2;;
-h|--help) sed -n '2,8p' "$0"; exit 0;;
--prune-unreferenced-libs) PRUNE_LIBS=1; shift;;
-h|--help) sed -n '2,11p' "$0"; exit 0;;
*) echo "unknown argument: $1" >&2; exit 2;;
esac
done
@@ -102,6 +107,20 @@ export NO_STRIP=1
"$LINUXDEPLOY" --appimage-extract-and-run --appdir "$APPDIR" \
--executable "$APPDIR/usr/bin/$EXE_NAME" --desktop-file "$APPDIR/$DESKTOP_ID.desktop" \
--icon-file "$APPDIR/$DESKTOP_ID.png"
if [[ "$PRUNE_LIBS" == 1 ]]; then
KEEP="$(mktemp "${TMPDIR:-/tmp}/psx-appimage-keep.XXXXXXXX")"
env -u LD_LIBRARY_PATH ldd "$APPDIR/usr/bin/$EXE_NAME" |
awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^\//) print $i }' |
while read -r lib; do readlink -f "$lib"; done | sort -u > "$KEEP"
for lib in "$APPDIR"/usr/lib/*; do
[[ -e "$lib" ]] || continue
grep -qxF "$(readlink -f "$lib")" "$KEEP" || { echo "pruned unreferenced $(basename "$lib")"; rm -f -- "$lib"; }
done
rm -f -- "$KEEP"
fi
if env -u LD_LIBRARY_PATH ldd "$APPDIR/usr/bin/$EXE_NAME" | grep 'not found'; then
echo "unresolved libraries in the deployed executable" >&2; exit 1
fi
# Deployment may rewrite RPATH. The original sidecar supplies the contract,
# while the helper verifies its embedded identity and hashes the FINAL ELF;
# a prior payload binary_sha256 is never accepted as the final-byte receipt.
+26 -1
View File
@@ -39,6 +39,14 @@
# the same deliberately unwieldy switch release_stage.py exposes, because a
# packager that quietly staged nothing once looked like a good release twice.
#
# RELEASE CHANNEL. The build tree must have been configured with
# -DPSX_RELEASE_CHANNEL=public|playtest (runtime/psx_release_channel.cmake);
# a tree without one is a dev build and is refused. playtest packages are
# named <prefix>-<version>-playtest-<artifact>.zip, public packages always
# drop developer-channel mods, and the staged Playtest Diagnostics defaults
# must match the channel. Local title releases use
# tools/package_title_release.py instead (docs/RELEASING_A_TITLE.md).
#
# DEVELOPER CHANNEL. mods with channel = "developer" are pruned from public
# releases (EXCLUDE_DEV_MODS=1, which CI sets via $CI) and kept in local
# exports; --include-dev-mods / --exclude-dev-mods override. Pruning is by
@@ -198,6 +206,18 @@ if ! grep -qs 'PSX_HAS_GAME_DISPATCH' "${BUILD_DIR}/build.ninja" "${BUILD_DIR}/c
exit 1
fi
# --- Release channel: from the build, never defaulted -------------------------
CHANNEL="$(sed -n 's/^PSX_RELEASE_CHANNEL:[A-Z]*=//p' "${BUILD_DIR}/CMakeCache.txt" 2>/dev/null | head -n1 || true)"
case "${CHANNEL}" in
public) CHANNEL_SUFFIX=""; EXCLUDE_DEV_MODS=1 ;;
playtest) CHANNEL_SUFFIX="-playtest" ;;
*)
echo "error: ${BUILD_DIR} has no release channel (PSX_RELEASE_CHANNEL='${CHANNEL}')." >&2
echo " Configure the release build with -DPSX_RELEASE_CHANNEL=public or =playtest." >&2
exit 1 ;;
esac
echo "release channel: ${CHANNEL}"
normalize_ver() { local v; v="$(printf '%s' "${1:-}" | tr -d '[:space:]')"; printf '%s' "${v#v}"; }
BUILT=""
for stamp in "${EXE_DIR}/psx_game_version.txt" "${BUILD_DIR}/psx_game_version.txt" "${BUILD_DIR}/Release/psx_game_version.txt"; do
@@ -222,7 +242,7 @@ printf '%s\n' "${VERSION}" >"${ROOT}/VERSION"
DIST="${ROOT:?}/dist"
STAGE="${DIST:?}/stage-game-${ARTIFACT:?}"
ZIP_NAME="${ZIP_PREFIX:?}-${VERSION:?}-${ARTIFACT:?}.zip"
ZIP_NAME="${ZIP_PREFIX:?}-${VERSION:?}${CHANNEL_SUFFIX}-${ARTIFACT:?}.zip"
rm -rf "${STAGE:?}"
mkdir -p "${STAGE}" "${DIST}"
if [[ "${STAGE_ONLY}" != 1 ]]; then rm -f "${DIST:?}/${ZIP_NAME:?}"; fi
@@ -338,6 +358,11 @@ else
[[ "${n}" -eq 0 ]] || echo "note: package includes ${n} developer-channel manifest(s); pass --exclude-dev-mods for a public release"
fi
if ! "${PY}" "${SCRIPT_DIR}/package_title_release.py" check-diagnostics --channel "${CHANNEL}" "${STAGE}/mods/bundled"; then
echo "error: Playtest Diagnostics defaults do not match the ${CHANNEL} channel" >&2
exit 1
fi
# --- Overlay shard cache (developer-local only) and toolchain -----------------
GAME_BIN=""
for cand in "${RECOMPILER_BUILD}/psxrecomp-game" "${RECOMPILER_BUILD}/psxrecomp-game.exe" "${RECOMPILER_BUILD}/Release/psxrecomp-game.exe"; do
+19
View File
@@ -0,0 +1,19 @@
# Windows launcher for tools/package_title_release.py (the one release command).
# Run from the title repository root:
# psxrecomp/tools/package_title_release.ps1 --channel public|playtest [--platform windows|linux|all]
# Arguments pass through unchanged; --channel is required (no default).
# Exit code is the packager's; failures end with "PACKAGING FAILED: <reason>".
$ErrorActionPreference = 'Stop'
$script = Join-Path $PSScriptRoot 'package_title_release.py'
$python = $env:PSX_RELEASE_PYTHON
$prefix = @()
if (-not $python) {
if (Get-Command py -ErrorAction SilentlyContinue) { $python = 'py'; $prefix = @('-3') }
elseif (Get-Command python -ErrorAction SilentlyContinue) { $python = 'python' }
else {
Write-Host 'PACKAGING FAILED: Python 3.11+ not found (install it or set PSX_RELEASE_PYTHON)' -ForegroundColor Red
exit 1
}
}
& $python @prefix $script @args
exit $LASTEXITCODE
+1391
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Linux/macOS-shell launcher for tools/package_title_release.py (the one release
# command). Run from the title repository root:
# psxrecomp/tools/package_title_release.sh --channel public|playtest
# Arguments pass through unchanged; --channel is required (no default).
set -euo pipefail
python="${PSX_RELEASE_PYTHON:-python3}"
command -v "$python" >/dev/null 2>&1 || {
printf '\nPACKAGING FAILED: %s not found (Python 3.11+ required)\n' "$python" >&2; exit 1;
}
exec "$python" "$(cd "$(dirname "$0")" && pwd)/package_title_release.py" "$@"
@@ -186,6 +186,34 @@ class ArchiveTests(unittest.TestCase):
self.check()
self.files[name] = content
def test_title_recorder_without_replay_helper(self):
# A title with its own recorder ships no replay-diagnostics.py.
del self.files["replay-diagnostics.py"]
self.write()
with self.assertRaises(ValueError):
audit.audit(self.zip, GAME, DIAG, True)
self.assertEqual(audit.audit(self.zip, GAME, DIAG, True, replay_helper=False), 1)
del self.files["Game.exe"]
self.write()
with self.assertRaisesRegex(ValueError, "no executable at the package root"):
audit.audit(self.zip, GAME, DIAG, True, replay_helper=False)
def test_staged_directory_is_audited_like_a_zip(self):
stage = Path(self.tmp.name) / "stage"
for name, content in self.files.items():
path = stage / name
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, str):
content = content.encode()
path.write_bytes(content)
self.assertEqual(audit.audit(stage, GAME, DIAG, True), 1)
with self.assertRaises(ValueError):
audit.audit(stage, GAME, DIAG, False)
(stage / "saves").mkdir()
(stage / "saves/card1.mcd").write_bytes(b"card")
with self.assertRaisesRegex(ValueError, "personal state"):
audit.audit(stage, GAME, DIAG, True)
def test_wrong_game_recipe_and_manifest_target(self):
self.files["game.toml"] = '[game]\nid="WRONG"\n'
with self.assertRaisesRegex(ValueError, "game ID"):
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""Unit tests for tools/package_title_release.py (no build, no disc, no game).
Pins the release contract every title now shares: --channel is required with
no default, owned CMake flags cannot be overridden by a title, artifact names
follow the channel, forbidden content fails the payload gates, diagnostics
defaults follow the channel, and a finished ZIP is re-verified by extraction.
"""
import contextlib
import hashlib
import io
import json
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import package_title_release as ptr # noqa: E402
DIAG = '''format_version = 5
id = "fixture.diagnostics"
version = "1.0.0"
name = "Playtest Diagnostics"
channel = "stable"
[[target]]
game_id = "SLUS-00000"
[[feature]]
id = "recording"
default_enabled = {on}
[[feature]]
id = "osd"
default_enabled = {on}
[[plugin]]
feature = "recording"
id = "fixture.diagnostics"
[[plugin]]
feature = "osd"
id = "fixture.diagnostics.osd"
'''
CONFIG = '''schema = 1
[product]
name = "FixtureRecomp"
display_name = "Fixture Recompiled"
exe_name = "Fixture"
version_file = "VERSION"
[build]
cmake_args = ["-DPSX_PGXP_VARIANT=OFF"]
[diagnostics]
game_id = "SLUS-00000"
package_id = "fixture.diagnostics"
[payload]
start_here = "START_HERE.txt"
files = ["README.md", {{ from = "notes/input.ini", to = "input.ini" }}]
{extra}
'''
class Fixture:
def __init__(self, extra=''):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
(self.root / 'VERSION').write_text('v1.2.3\n')
(self.root / 'START_HERE.txt').write_text('Fixture @VERSION@\n')
(self.root / 'README.md').write_text('readme\n')
(self.root / 'game.toml').write_text('[game]\nid = "SLUS-00000"\n')
(self.root / 'notes').mkdir()
(self.root / 'notes/input.ini').write_text('[controller]\n')
(self.root / 'release.toml').write_text(CONFIG.format(extra=extra))
def config(self):
return ptr.Config(self.root / 'release.toml', self.root)
def close(self):
self.tmp.cleanup()
def write_stage(stage, channel, version='v1.2.3', exe='Fixture.exe'):
stage.mkdir(parents=True)
(stage / exe).write_bytes(b'MZ fixture')
(stage / 'psx_game_version.txt').write_text(version + '\n')
(stage / 'START_HERE.txt').write_text('Fixture %s\n' % version)
(stage / 'game.toml').write_text('[game]\nid = "SLUS-00000"\n')
(stage / 'replay-diagnostics.py').write_text('# helper\n')
(stage / 'bios').mkdir()
(stage / 'bios/openbios.bin').write_bytes(b'\0' * ptr.OPENBIOS_SIZE)
package = stage / 'mods/bundled/fixture.diagnostics/1.0.0'
package.mkdir(parents=True)
(package / 'manifest.toml').write_text(
DIAG.format(on='true' if channel == 'playtest' else 'false'))
files = {p.relative_to(stage).as_posix(): ptr.sha256(p)
for p in sorted(stage.rglob('*')) if p.is_file()}
manifest = dict(channel=channel, version=version, product='FixtureRecomp', executable=exe,
mod_packages=['fixture.diagnostics'],
playtest_diagnostics_default_enabled=channel == 'playtest', files=files)
(stage / 'RELEASE_MANIFEST.json').write_text(json.dumps(manifest))
def zip_stage(stage, artifact):
with zipfile.ZipFile(artifact, 'w') as archive:
for path in sorted(stage.rglob('*')):
if path.is_file():
archive.write(path, path.relative_to(stage).as_posix())
Path(str(artifact) + '.sha256').write_text(
'%s %s\n' % (hashlib.sha256(artifact.read_bytes()).hexdigest(), artifact.name))
class ChannelIsRequired(unittest.TestCase):
def run_main(self, argv):
err = io.StringIO()
with contextlib.redirect_stderr(err), self.assertRaises(SystemExit) as raised:
ptr.main(argv)
return raised.exception.code, err.getvalue()
def test_missing_channel_fails_loudly(self):
code, err = self.run_main(['--platform', 'windows'])
self.assertNotEqual(code, 0)
self.assertIn('PACKAGING FAILED', err)
self.assertIn('--channel', err)
def test_unknown_channel_fails(self):
code, err = self.run_main(['--channel', 'release'])
self.assertNotEqual(code, 0)
self.assertIn('PACKAGING FAILED', err)
def test_verify_also_requires_channel(self):
code, _ = self.run_main(['verify', 'x.zip'])
self.assertNotEqual(code, 0)
def test_default_subcommand_is_package(self):
args = ptr.parse_args(['--channel', 'playtest'])
self.assertEqual((args.command, args.channel), ('package', 'playtest'))
class ConfigTests(unittest.TestCase):
def setUp(self):
self.fixture = Fixture()
self.addCleanup(self.fixture.close)
def test_loads(self):
config = self.fixture.config()
self.assertEqual(config.version(), 'v1.2.3')
self.assertEqual([rel for _, rel in config.files], ['README.md', 'input.ini'])
self.assertEqual(config.aot_mode, 'none')
self.assertTrue(config.diagnostics['replay_helper'])
def test_owned_cmake_flags_are_refused(self):
for flag in ('PSX_PUBLIC_RELEASE', 'PSX_RELEASE_CHANNEL', 'PSX_DEBUG_TOOLS',
'PSXRECOMP_BIOS_STEMS', 'CMAKE_BUILD_TYPE'):
text = CONFIG.format(extra='').replace(
'"-DPSX_PGXP_VARIANT=OFF"', '"-D%s=ON"' % flag)
(self.fixture.root / 'release.toml').write_text(text)
with self.assertRaisesRegex(ptr.PackagingError, 'packager owns'):
self.fixture.config()
def test_unknown_key_is_an_error(self):
(self.fixture.root / 'release.toml').write_text(
CONFIG.format(extra='').replace('[build]', '[build]\nskip_audit = true'))
with self.assertRaisesRegex(ptr.PackagingError, 'unknown key'):
self.fixture.config()
def test_missing_payload_file_is_an_error(self):
(self.fixture.root / 'README.md').unlink()
with self.assertRaisesRegex(ptr.PackagingError, 'not found'):
self.fixture.config()
def test_package_path_cannot_escape(self):
(self.fixture.root / 'release.toml').write_text(
CONFIG.format(extra='').replace('to = "input.ini"', 'to = "../input.ini"'))
with self.assertRaisesRegex(ptr.PackagingError, 'relative path'):
self.fixture.config()
class NamingTests(unittest.TestCase):
def test_artifact_names_follow_channel(self):
fixture = Fixture()
self.addCleanup(fixture.close)
config = fixture.config()
self.assertEqual(ptr.artifact_name(config, 'v1.2.3', 'public', 'windows'),
'FixtureRecomp-v1.2.3-windows-x64.zip')
self.assertEqual(ptr.artifact_name(config, '1.2.3', 'playtest', 'windows'),
'FixtureRecomp-v1.2.3-playtest-windows-x64.zip')
self.assertEqual(ptr.artifact_name(config, '0.2.4-alpha', 'playtest', 'linux'),
'FixtureRecomp-v0.2.4-alpha-playtest-linux-x86_64.AppImage')
class GateTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.stage = Path(self.tmp.name) / 'stage'
write_stage(self.stage, 'public')
def test_clean_stage_passes(self):
ptr.payload_gates(self.stage, 'windows', 'Fixture.exe')
def assert_refused(self, rel, data=b'x', message=None):
path = self.stage / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
with self.assertRaises(ptr.PackagingError) as raised:
ptr.payload_gates(self.stage, 'windows', 'Fixture.exe')
if message:
self.assertIn(message, str(raised.exception))
top = self.stage / Path(rel).parts[0]
if top.is_dir() and top.name not in ('bios', 'mods'):
shutil.rmtree(top)
else:
path.unlink()
def test_forbidden_content(self):
self.assert_refused('Game (USA).cue', message='disc')
self.assert_refused('bios/SCPH1001.BIN', message='image')
self.assert_refused('saves/card1.mcd')
self.assert_refused('settings.toml', message='personal')
self.assert_refused('mods/installed/x.psxmod', message='personal')
self.assert_refused('state.pst', message='disc')
self.assert_refused('lib/game_full_1.c', message='source')
self.assert_refused('generated/x.h', message='build-kit')
def test_toolchain_sources_are_allowed(self):
(self.stage / 'overlay_toolchain/tcc/examples').mkdir(parents=True)
(self.stage / 'overlay_toolchain/tcc/examples/fib.c').write_text('int x;')
ptr.payload_gates(self.stage, 'windows', 'Fixture.exe')
def test_openbios_required(self):
(self.stage / 'bios/openbios.bin').write_bytes(b'short')
with self.assertRaisesRegex(ptr.PackagingError, 'OpenBIOS'):
ptr.payload_gates(self.stage, 'windows', 'Fixture.exe')
def test_windows_binary_in_linux_package(self):
(self.stage / 'Fixture').write_bytes(b'\x7fELF')
(self.stage / 'extra.dll').write_bytes(b'MZ')
with self.assertRaisesRegex(ptr.PackagingError, 'Windows binary'):
ptr.payload_gates(self.stage, 'linux', 'Fixture')
def test_template_tokens_must_all_be_stamped(self):
template = Path(self.tmp.name) / 'AppRun'
template.write_text('echo @VERSION@ @UNKNOWN_TOKEN@\n')
with self.assertRaisesRegex(ptr.PackagingError, 'UNKNOWN_TOKEN'):
ptr.render_template(template, {'VERSION': '1'})
template.write_text('echo @VERSION@\n')
self.assertEqual(ptr.render_template(template, {'VERSION': '1'}), 'echo 1\n')
class DiagnosticsTests(unittest.TestCase):
def setUp(self):
self.fixture = Fixture()
self.addCleanup(self.fixture.close)
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def ctx(self, channel):
ctx = ptr.Context()
ctx.config, ctx.channel = self.fixture.config(), channel
return ctx
def test_defaults_follow_channel(self):
for staged, channel, ok in (('public', 'public', True), ('playtest', 'playtest', True),
('playtest', 'public', False), ('public', 'playtest', False)):
stage = Path(self.tmp.name) / ('%s-%s' % (staged, channel))
write_stage(stage, staged)
if ok:
ptr.check_diagnostics(self.ctx(channel), stage)
else:
with self.assertRaisesRegex(ptr.PackagingError, 'default_enabled'):
ptr.check_diagnostics(self.ctx(channel), stage)
def test_undeclared_diagnostics_package_fails(self):
(self.fixture.root / 'release.toml').write_text(
CONFIG.format(extra='').replace(
'[diagnostics]\ngame_id = "SLUS-00000"\npackage_id = "fixture.diagnostics"\n', ''))
stage = Path(self.tmp.name) / 'stage'
write_stage(stage, 'public')
with self.assertRaisesRegex(ptr.PackagingError, 'no \\[diagnostics\\]'):
ptr.check_diagnostics(self.ctx('public'), stage)
class VerifyZipTests(unittest.TestCase):
def setUp(self):
self.fixture = Fixture()
self.addCleanup(self.fixture.close)
self.config = self.fixture.config()
self.out = Path(self.fixture.root) / 'dist'
self.out.mkdir()
def build(self, channel, staged_channel=None):
stage = Path(self.fixture.root) / ('stage-' + channel)
write_stage(stage, staged_channel or channel)
artifact = self.out / ptr.artifact_name(self.config, 'v1.2.3', channel, 'windows')
zip_stage(stage, artifact)
return artifact
def test_public_and_playtest_verify(self):
for channel in ptr.CHANNELS:
manifest = ptr.verify_artifact(self.build(channel), self.config, channel, 'v1.2.3')
self.assertEqual(manifest['channel'], channel)
def test_wrong_channel_fails(self):
artifact = self.build('public')
with self.assertRaises(ptr.PackagingError):
ptr.verify_artifact(artifact, self.config, 'playtest', 'v1.2.3')
def test_tampered_payload_fails(self):
artifact = self.build('public')
with zipfile.ZipFile(artifact, 'a') as archive:
archive.writestr('saves/card.mcd', b'card')
Path(str(artifact) + '.sha256').write_text(
'%s %s\n' % (ptr.sha256(artifact), artifact.name))
with self.assertRaises(ptr.PackagingError):
ptr.verify_artifact(artifact, self.config, 'public', 'v1.2.3')
def test_stale_checksum_fails(self):
artifact = self.build('public')
Path(str(artifact) + '.sha256').write_text('0' * 64 + ' x\n')
with self.assertRaisesRegex(ptr.PackagingError, 'sha256'):
ptr.verify_artifact(artifact, self.config, 'public', 'v1.2.3')
if __name__ == '__main__':
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Configure-time backstop of runtime/psx_release_channel.cmake.
Usage: test_release_channel_cmake.py [--cmake PATH]
A release-package build must name its channel; PSX_PUBLIC_RELEASE is derived
from it. Each case configures a throwaway project that includes the module.
"""
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
MODULE = ROOT / "runtime/psx_release_channel.cmake"
CMAKE = os.environ.get("PSX_TEST_CMAKE") or shutil.which("cmake")
class ReleaseChannelTests(unittest.TestCase):
def setUp(self):
if not CMAKE:
self.skipTest("cmake not found")
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.src = Path(self.tmp.name) / "src"
self.src.mkdir()
(self.src / "CMakeLists.txt").write_text(
"cmake_minimum_required(VERSION 3.20)\nproject(channel_fixture NONE)\n"
'include("%s")\n'
'file(WRITE "${CMAKE_BINARY_DIR}/result.txt" '
'"${PSX_RELEASE_CHANNEL}|${PSX_PUBLIC_RELEASE}")\n' % MODULE.as_posix())
self.build = Path(self.tmp.name) / "build"
def configure(self, *defs):
result = subprocess.run([CMAKE, "-S", str(self.src), "-B", str(self.build)] + list(defs),
capture_output=True, text=True)
return result
def result(self):
return (self.build / "result.txt").read_text()
def test_dev_build_has_no_channel(self):
r = self.configure()
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.result(), "|OFF")
self.assertIn("not packageable", r.stdout)
def test_public_channel_derives_public_release(self):
r = self.configure("-DPSX_RELEASE_CHANNEL=public")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.result(), "public|ON")
def test_playtest_channel(self):
r = self.configure("-DPSX_RELEASE_CHANNEL=playtest")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.result(), "playtest|OFF")
def test_switching_channel_on_the_same_tree(self):
self.assertEqual(self.configure("-DPSX_RELEASE_CHANNEL=public").returncode, 0)
r = self.configure("-DPSX_RELEASE_CHANNEL=playtest")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.result(), "playtest|OFF")
def test_unknown_channel_fails(self):
r = self.configure("-DPSX_RELEASE_CHANNEL=release")
self.assertNotEqual(r.returncode, 0)
self.assertIn("must be 'public' or 'playtest'", r.stderr)
def test_public_release_without_channel_fails(self):
# The pre-channel way to ask for a public package: forgetting the
# channel must not configure.
r = self.configure("-DPSX_PUBLIC_RELEASE=ON")
self.assertNotEqual(r.returncode, 0)
self.assertIn("without a release channel", r.stderr)
def test_contradiction_fails(self):
r = self.configure("-DPSX_RELEASE_CHANNEL=playtest", "-DPSX_PUBLIC_RELEASE=ON")
self.assertNotEqual(r.returncode, 0)
self.assertIn("contradicts", r.stderr)
r = self.configure("-UPSX_PUBLIC_RELEASE", "-DPSX_RELEASE_CHANNEL=public",
"-DPSX_PUBLIC_RELEASE=OFF")
self.assertNotEqual(r.returncode, 0)
self.assertIn("contradicts", r.stderr)
def test_consistent_explicit_value_is_accepted(self):
r = self.configure("-DPSX_RELEASE_CHANNEL=public", "-DPSX_PUBLIC_RELEASE=ON")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.result(), "public|ON")
def test_legacy_off_without_channel_stays_a_dev_build(self):
# Every dev tree configured by the old option() carries OFF.
r = self.configure("-DPSX_PUBLIC_RELEASE:BOOL=OFF")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(self.result(), "|OFF")
def test_source_commit_must_be_full(self):
r = self.configure("-DPSX_SOURCE_COMMIT=abc123")
self.assertNotEqual(r.returncode, 0)
r = self.configure("-DPSX_SOURCE_COMMIT=" + "a" * 40)
self.assertEqual(r.returncode, 0, r.stderr)
if __name__ == "__main__":
if "--cmake" in sys.argv:
index = sys.argv.index("--cmake")
CMAKE = sys.argv[index + 1]
del sys.argv[index:index + 2]
unittest.main()