mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
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:
@@ -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 =
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable
+1391
File diff suppressed because it is too large
Load Diff
Executable
+11
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user