From ce00e3f5180aa718acf987f2aae37a30706d9838 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 13 Sep 2026 20:09:55 -0400 Subject: [PATCH] Add PyInstaller spec and Gitea Actions CI for Linux/Windows binaries - run.py: top-level entry point for PyInstaller (relative imports need it) - psp-preserve.spec: onedir build bundling DATs, loader, cover bundles, icons - requirements-build.txt: pinned build deps (PySide6 6.8.3 for Windows ICU fix) - .gitea/workflows/build.yml: builds linux + windows artifacts on tags/manual - gitignore build/ dist/ --- .gitea/workflows/build.yml | 60 +++++++++++++++++++++++++++++++++++ .gitignore | 6 +++- README.md | 51 ++++++++++++++++++++++++++++++ psp-preserve.spec | 65 ++++++++++++++++++++++++++++++++++++++ requirements-build.txt | 7 ++++ run.py | 10 ++++++ 6 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/build.yml create mode 100644 psp-preserve.spec create mode 100644 requirements-build.txt create mode 100644 run.py diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..7f4b3f6 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,60 @@ +name: build + +on: + push: + tags: ["v*"] + workflow_dispatch: + +jobs: + linux: + name: Linux x86_64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-build.txt + + - name: Build + run: pyinstaller --clean --noconfirm psp-preserve.spec + + - name: Package tarball + run: tar -czf psp-preserve-linux-x86_64.tar.gz -C dist psp-preserve + + - uses: actions/upload-artifact@v4 + with: + name: psp-preserve-linux-x86_64 + path: psp-preserve-linux-x86_64.tar.gz + + windows: + name: Windows x64 + runs-on: windows + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-build.txt + + - name: Build + run: pyinstaller --clean --noconfirm psp-preserve.spec + + - name: Package zip + shell: pwsh + run: Compress-Archive -Path dist\psp-preserve -DestinationPath psp-preserve-windows-x64.zip + + - uses: actions/upload-artifact@v4 + with: + name: psp-preserve-windows-x64 + path: psp-preserve-windows-x64.zip diff --git a/.gitignore b/.gitignore index 05e1871..1d95b67 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,8 @@ data/ps1_templates/ data/nps/*.tsv # Local environment overrides (LAN paths, secrets). See .env.example -.env \ No newline at end of file +.env + +# PyInstaller output +build/ +dist/ \ No newline at end of file diff --git a/README.md b/README.md index 6d0a7ab..c464f78 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,57 @@ pip install -r requirements.txt python3 -m app.main ``` +## Building binaries + +The GUI is packaged with PyInstaller into a self-contained folder. Build +requirements are pinned in `requirements-build.txt` (PySide6 6.8.3 on purpose — +see the note inside). + +```bash +python3 -m venv .venv-build +. .venv-build/bin/activate +pip install -r requirements-build.txt +pyinstaller --clean --noconfirm psp-preserve.spec +# output: dist/psp-preserve/ → run dist/psp-preserve/psp-preserve +``` + +Notes: + +- The build bundles the DATs, PS1 loader, and the offline cover bundles. The + cover zips are large (~850 MB), so builds are slow and the output is big. +- `7z` (p7zip-full on Linux, 7-Zip on Windows) is a **runtime** dependency for + reading store archives — it is not bundled. +- User data (credentials, manual covers, PS1 templates, NPS cache, library DB, + cover cache) lives in the OS state dir, never next to the binary. + +### CI (Gitea Actions) + +`.gitea/workflows/build.yml` builds Linux and Windows artifacts on version-tag +pushes (`v*`) or on manual trigger (**Actions → build → Run workflow**). +Artifacts appear on the Actions run page and can be attached to a release. + +Each job needs a [Gitea Actions runner](https://docs.gitea.com/usage/actions/act-runner) +registered with the matching label: + +```bash +# Linux runner (Docker mode, run on your server) +./act_runner register \ + --instance https://git.ceaf.dev \ + --token \ + --name linux-builder \ + --labels ubuntu-latest:docker://node:20-bookworm + +# Windows runner (host mode, run on a Windows machine) +act_runner.exe register \ + --instance https://git.ceaf.dev \ + --token \ + --name win-builder \ + --labels windows:host +``` + +The registration token is generated in Gitea under +**Site Administration → Actions → Runners → Create new runner**. + ## First-time setup 1. Put your DAT files in `data/dats/`: diff --git a/psp-preserve.spec b/psp-preserve.spec new file mode 100644 index 0000000..9c8292d --- /dev/null +++ b/psp-preserve.spec @@ -0,0 +1,65 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller spec: build the PSP Preservation Tool (onedir). + +Bundles the read-only resources (DATs, PS1 loader, offline cover bundles, +icons) under ``_internal/`` next to the executable. User-writable data +(credentials, manual covers, PS1 templates, NPS cache, library DB, cover +cache) lives in the OS state dir at runtime — see ``app/config.py``. + +Usage: pyinstaller --clean --noconfirm psp-preserve.spec +Output: dist/psp-preserve/ +""" +from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules + +datas = [ + ("data/dats", "data/dats"), + ("data/loader", "data/loader"), + ("data/supplement.json", "data"), + ("data/covers/libretro-covers.zip", "data/covers"), + ("data/covers/openvgdb-covers.zip", "data/covers"), + ("data/covers/libretro-index.json", "data/covers"), + ("data/covers/openvgdb-index.json", "data/covers"), + ("data/covers/UMD_COVER_TEMPLATE.png", "data/covers"), + ("data/covers/UMD_COVER_TEMPLATE_FEATURE.png", "data/covers"), + ("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"), + ("img/app-icon.png", "img"), + ("img/chevron.png", "img"), +] + +a = Analysis( + ["run.py"], + pathex=[], + binaries=collect_dynamic_libs("curl_cffi"), + datas=datas, + hiddenimports=collect_submodules("curl_cffi"), + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["tkinter", "unittest", "pydoc"], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="psp-preserve", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + disable_windowed_traceback=False, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + name="psp-preserve", +) diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..88752d1 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,7 @@ +# Build dependencies (pinned for reproducible CI builds). +# PySide6 is pinned to 6.8.3: newer 6.11.x bundles a Qt6Core.dll that imports +# icuuc.dll on Windows, which is not shipped and crashes at startup. +pyinstaller>=6.11 +PySide6==6.8.3 +curl_cffi>=0.7 +pycryptodome>=3.20 diff --git a/run.py b/run.py new file mode 100644 index 0000000..3e1d80d --- /dev/null +++ b/run.py @@ -0,0 +1,10 @@ +"""PyInstaller entry point. + +Runs the GUI the same way ``python -m app.main`` does, but as a plain script +so PyInstaller can build it (relative imports inside ``app`` won't resolve when +a module file is executed directly, hence this top-level launcher). +""" +from app.main import main + +if __name__ == "__main__": + raise SystemExit(main())