mirror of
https://github.com/phantomptr/ps5upload.git
synced 2026-10-19 02:20:02 -05:00
Deletes tests/install-fallback-hw.mjs, the mobile audit probe and its allowlist, scripts/requirements.txt, tests/lab/verify-pending.sh, bench/baselines/ and client/icons/ (nothing references them; the bundle icons live in client/src-tauri/icons). audit-scripts lists fpkg-diff.py as a manual research tool and no longer counts a script naming itself as a reference. SPEC.md no longer says zip/7z/RAR sources fall back to FTX2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ub1mez9eaVtTF8pMEFXvA9
973 lines
42 KiB
YAML
973 lines
42 KiB
YAML
name: ci
|
|
|
|
# Runs on every push to main and every PR. No user-controlled inputs
|
|
# interpolate into shell commands anywhere in this file — all `run:`
|
|
# blocks use static strings only.
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
# Explicitly *not* tags — tag pushes are the release workflow's
|
|
# trigger. Leaving this list out would inherit the "all refs"
|
|
# default and double-fire on `vX.Y.Z` tags that point at HEAD,
|
|
# wasting a full CI run on a commit the release workflow is
|
|
# already validating end-to-end.
|
|
tags-ignore: ['**']
|
|
pull_request:
|
|
workflow_dispatch:
|
|
|
|
# One concurrency group per ref: if you push twice in a row (e.g.
|
|
# CHANGELOG typo fix on main), the first run cancels instead of
|
|
# keeping two redundant jobs alive.
|
|
concurrency:
|
|
group: ci-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
env:
|
|
CARGO_TERM_COLOR: always
|
|
|
|
jobs:
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Version sync gate. VERSION (repo root) is the canonical source of
|
|
# truth; downstream files (payload config.h, Tauri conf, Cargo.toml,
|
|
# package manifests) must all match it. Runs first because if this
|
|
# fails there's no point burning cycles on clippy/tests for a release
|
|
# that would ship with mismatched version strings on the payload
|
|
# binary vs the desktop bundle.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
version-sync:
|
|
name: version sync (VERSION is canonical)
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version: 22
|
|
- name: Verify downstream files match VERSION
|
|
run: node scripts/update-version.js --check
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Rust workspace (engine, core, AVA1 crates). Format, clippy, tests.
|
|
# Gates PRs that touch any Rust code.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
rust-engine:
|
|
name: rust (engine workspace)
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 60
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install Rust stable
|
|
uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
components: rustfmt, clippy
|
|
|
|
# Restored on every run; saved (under a new key per run, so it stays current) only from
|
|
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
|
|
- name: Restore cargo cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
engine/target
|
|
key: ${{ runner.os }}-engine-lint-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
|
|
restore-keys: |
|
|
${{ runner.os }}-engine-lint-${{ hashFiles('engine/Cargo.lock') }}-
|
|
${{ runner.os }}-engine-lint-
|
|
|
|
- name: cargo fmt
|
|
working-directory: engine
|
|
run: cargo fmt --all -- --check
|
|
|
|
- name: cargo clippy
|
|
working-directory: engine
|
|
run: cargo clippy --workspace -- -D warnings
|
|
|
|
# The webui feature (rust-embed + static serving) is only enabled
|
|
# in the Docker webui build, not the default workspace clippy/test
|
|
# above. Compile-check it here so a broken #[cfg(feature = "webui")]
|
|
# branch doesn't slip through CI and only surface at Docker build
|
|
# time (as happened in 4.1.0). Full test/lint of the feature isn't
|
|
# needed — a type-check catches the class of bug we're guarding.
|
|
#
|
|
# rust-embed's #[derive(RustEmbed)] #[folder = "webui/"] requires
|
|
# the directory to exist at compile time — without it the macro
|
|
# generates no impl and WebAssets::get fails (E0599). The real
|
|
# content is the Vite build output (client/dist/) and is only
|
|
# copied in by Dockerfile.webui; here we create a stub with a
|
|
# dummy index.html so the derive macro is satisfied.
|
|
- name: create webui stub for rust-embed
|
|
working-directory: engine/crates/ps5upload-engine
|
|
run: mkdir -p webui && echo '<!DOCTYPE html><title>stub</title>' > webui/index.html
|
|
- name: cargo check (webui feature)
|
|
working-directory: engine
|
|
run: cargo check -p ps5upload-engine --features webui
|
|
|
|
- name: Save cargo cache
|
|
if: github.ref == 'refs/heads/main'
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
engine/target
|
|
key: ${{ runner.os }}-engine-lint-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
|
|
|
|
# The workspace's tests, in two legs that run side by side: the engine crate (and the
|
|
# integration suite built on it) is about half of the time, everything else the other half.
|
|
# Together they are `cargo test --workspace --exclude ava1-ctest`; a crate added later lands
|
|
# in `libs` without anyone listing it.
|
|
rust-engine-tests:
|
|
name: rust tests (${{ matrix.part }})
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 40
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- part: engine
|
|
select: -p ps5upload-engine -p ps5upload-tests
|
|
- part: libs
|
|
select: --workspace --exclude ava1-ctest --exclude ps5upload-engine --exclude ps5upload-tests
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install Rust stable
|
|
uses: dtolnay/rust-toolchain@stable
|
|
|
|
# Restored on every run; saved (under a new key per run, so it stays current) only from
|
|
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
|
|
- name: Restore cargo cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
engine/target
|
|
key: ${{ runner.os }}-engine-test-${{ matrix.part }}-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
|
|
restore-keys: |
|
|
${{ runner.os }}-engine-test-${{ matrix.part }}-${{ hashFiles('engine/Cargo.lock') }}-
|
|
${{ runner.os }}-engine-test-${{ matrix.part }}-
|
|
|
|
- name: Install cargo-nextest
|
|
uses: taiki-e/install-action@v2
|
|
with:
|
|
tool: cargo-nextest
|
|
|
|
# nextest runs the test binaries side by side (cargo test runs them one at a time, and most
|
|
# of these sit in network sleeps); doctests, which nextest does not run, follow separately.
|
|
# Timing-sensitive binaries are serialized by engine/.config/nextest.toml.
|
|
# Bounded per step: a hung test fails here in minutes instead of holding the job.
|
|
- name: cargo nextest
|
|
working-directory: engine
|
|
timeout-minutes: 25
|
|
env:
|
|
RUST_BACKTRACE: 1
|
|
run: cargo nextest run --profile ci ${{ matrix.select }}
|
|
|
|
- name: cargo test --doc
|
|
working-directory: engine
|
|
timeout-minutes: 10
|
|
run: cargo test --doc ${{ matrix.select }}
|
|
|
|
- name: Save cargo cache
|
|
if: github.ref == 'refs/heads/main'
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
engine/target
|
|
key: ${{ runner.os }}-engine-test-${{ matrix.part }}-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
|
|
|
|
# The C interop suite. The C server and the data layer are process-wide singletons, so the
|
|
# suite runs one test at a time: a test that holds the server cannot starve the others. That
|
|
# rule holds inside one machine, so the test files are dealt across four machines, each still
|
|
# running its share one at a time.
|
|
rust-ctest:
|
|
name: rust (ava1 C interop ${{ matrix.shard }}/4)
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 40
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4]
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install Rust stable
|
|
uses: dtolnay/rust-toolchain@stable
|
|
|
|
# Restored on every run; saved (under a new key per run, so it stays current) only from
|
|
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
|
|
- name: Restore cargo cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
engine/target
|
|
key: ${{ runner.os }}-engine-ctest-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
|
|
restore-keys: |
|
|
${{ runner.os }}-engine-ctest-${{ hashFiles('engine/Cargo.lock') }}-
|
|
${{ runner.os }}-engine-ctest-
|
|
|
|
- name: cargo test (ava1 C interop, serial)
|
|
working-directory: engine
|
|
timeout-minutes: 25
|
|
env:
|
|
RUST_BACKTRACE: 1
|
|
SHARD: ${{ matrix.shard }}
|
|
run: |
|
|
# Every test file, sorted, dealt round-robin: file N goes to shard (N mod 4) + 1.
|
|
# A file added later is picked up by one of the four without being listed anywhere.
|
|
mapfile -t all < <(ls crates/ava1-ctest/tests/*.rs | sort)
|
|
args=()
|
|
for i in "${!all[@]}"; do
|
|
if [ $(( i % 4 + 1 )) -eq "$SHARD" ]; then
|
|
args+=(--test "$(basename "${all[$i]}" .rs)")
|
|
fi
|
|
done
|
|
[ "${#args[@]}" -gt 0 ] || { echo "::error::shard $SHARD got no test files"; exit 1; }
|
|
echo "shard $SHARD of 4: ${#args[@]} of ${#all[@]} test files"
|
|
cargo test -p ava1-ctest "${args[@]}" -- --test-threads=1
|
|
|
|
- name: Save cargo cache
|
|
if: github.ref == 'refs/heads/main'
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
engine/target
|
|
key: ${{ runner.os }}-engine-ctest-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# AVA1: C/Rust conformance + interop runs in rust-engine-tests and rust-ctest
|
|
# above. This job adds what those cannot:
|
|
# fuzzing the C decoders and data-plane parsers under ASan/UBSan and the
|
|
# Rust decoders with cargo-fuzz.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
ava1:
|
|
name: ava1 (fuzz C + Rust decoders)
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 25
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install clang + libFuzzer
|
|
run: sudo apt-get update && sudo apt-get install -y clang libclang-rt-18-dev
|
|
|
|
- name: C frame AEAD (RFC 8439 + Monocypher differential, AVX2 and portable paths)
|
|
run: scripts/ava1-aead-test.sh
|
|
|
|
- name: No FTX2 or ports 9113/9114 in the repository
|
|
run: make check-no-ftx2
|
|
|
|
- name: Fuzz C decoders + data-plane parsers (60 s each)
|
|
env:
|
|
AVA1_FUZZ_SECONDS: 60
|
|
run: make ava1-fuzz-c
|
|
|
|
- name: Install Rust nightly + cargo-fuzz
|
|
run: |
|
|
rustup toolchain install nightly --profile minimal
|
|
cargo install cargo-fuzz --locked
|
|
|
|
- name: Fuzz Rust decoders (60 s + 30 s)
|
|
working-directory: engine/crates/ava1/fuzz
|
|
run: |
|
|
cargo +nightly fuzz run decode -- -max_total_time=60
|
|
cargo +nightly fuzz run frame_header -- -max_total_time=30
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# AVA1 sanitizers (review 009 #2a): the whole ctest suite, i.e. every console C path the host can run,
|
|
# with ASan + UBSan (+ LSan on Linux) on every C unit. A leak, use-after-free, overflow or UB in the
|
|
# payload C fails here instead of on a console with no debugger. Slow on purpose.
|
|
# engine/crates/ava1-ctest/lsan.supp lists the tests that exit on purpose, each with its reason.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
ava1-ctest-sanitize:
|
|
name: ava1-ctest (ASan + UBSan)
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 60
|
|
# Four shards, test files dealt round-robin exactly like rust-ctest: the serial sanitized run
|
|
# was ~15 min on one runner.
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
shard: [1, 2, 3, 4]
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install Rust stable
|
|
uses: dtolnay/rust-toolchain@stable
|
|
|
|
- name: Install clang + sanitizer runtimes
|
|
run: sudo apt-get update && sudo apt-get install -y clang libclang-rt-18-dev
|
|
|
|
# Restored on every run; saved (under a new key per run, so it stays current) only from
|
|
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
|
|
- name: Restore cargo cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
engine/target
|
|
key: ${{ runner.os }}-ava1-san-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
|
|
restore-keys: |
|
|
${{ runner.os }}-ava1-san-${{ hashFiles('engine/Cargo.lock') }}-
|
|
${{ runner.os }}-ava1-san-
|
|
|
|
- name: cargo test -p ava1-ctest (sanitized, serial)
|
|
working-directory: engine
|
|
env:
|
|
CC: clang
|
|
AVA1_CTEST_SANITIZE: '1'
|
|
RUSTFLAGS: -Clinker=clang
|
|
ASAN_OPTIONS: detect_leaks=1
|
|
LSAN_OPTIONS: suppressions=${{ github.workspace }}/engine/crates/ava1-ctest/lsan.supp:print_suppressions=0
|
|
RUST_BACKTRACE: 1
|
|
SHARD: ${{ matrix.shard }}
|
|
run: |
|
|
mapfile -t all < <(ls crates/ava1-ctest/tests/*.rs | sort)
|
|
args=()
|
|
for i in "${!all[@]}"; do
|
|
if [ $(( i % 4 + 1 )) -eq "$SHARD" ]; then
|
|
args+=(--test "$(basename "${all[$i]}" .rs)")
|
|
fi
|
|
done
|
|
[ "${#args[@]}" -gt 0 ] || { echo "::error::shard $SHARD got no test files"; exit 1; }
|
|
echo "sanitized shard $SHARD of 4: ${#args[@]} of ${#all[@]} test files"
|
|
cargo test -p ava1-ctest "${args[@]}" -- --test-threads=1
|
|
|
|
- name: Save cargo cache
|
|
if: github.ref == 'refs/heads/main'
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
engine/target
|
|
key: ${{ runner.os }}-ava1-san-${{ hashFiles('engine/Cargo.lock') }}-${{ github.run_id }}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Engine/core target portability. This is intentionally separate from
|
|
# the full release matrix: PRs get a fast compile-only signal that the
|
|
# host-side engine code still type-checks for the OS/arch combinations
|
|
# we ship, while publish.yml remains the authoritative bundle builder.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
rust-engine-targets:
|
|
name: rust engine target (${{ matrix.target }})
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- os: ubuntu-24.04
|
|
target: x86_64-unknown-linux-gnu
|
|
# Native arm64 runner — cross-compiling to aarch64 from x86_64
|
|
# ubuntu-24.04 fails because blake3's NEON SIMD path needs
|
|
# aarch64-linux-gnu-gcc (not preinstalled, and bringing it in
|
|
# would mean threading TARGET_CC env vars through every step).
|
|
# Matches publish.yml's arm64 build leg.
|
|
- os: ubuntu-24.04-arm
|
|
target: aarch64-unknown-linux-gnu
|
|
- os: macos-14
|
|
target: aarch64-apple-darwin
|
|
- os: macos-14
|
|
target: x86_64-apple-darwin
|
|
- os: windows-2022
|
|
target: x86_64-pc-windows-msvc
|
|
- os: windows-2022
|
|
target: aarch64-pc-windows-msvc
|
|
runs-on: ${{ matrix.os }}
|
|
timeout-minutes: 15
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install Rust stable
|
|
uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
targets: ${{ matrix.target }}
|
|
|
|
- name: cargo check core
|
|
working-directory: engine
|
|
run: cargo check -p ps5upload-core --target ${{ matrix.target }}
|
|
|
|
- name: cargo check engine
|
|
working-directory: engine
|
|
run: cargo check -p ps5upload-engine --target ${{ matrix.target }}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Rust desktop shell (client/src-tauri). Uses its own workspace so
|
|
# it doesn't share target/ with the engine. Tauri pulls heavy
|
|
# system deps on Linux (webkit/gtk), so we cache aggressively.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
rust-desktop:
|
|
name: rust desktop (${{ matrix.os }})
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-24.04, macos-14, windows-2022]
|
|
runs-on: ${{ matrix.os }}
|
|
timeout-minutes: 45
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install Rust stable
|
|
uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
components: rustfmt, clippy
|
|
|
|
# Formatting is platform-independent, so gate it on one runner
|
|
# rather than paying for it three times. This job previously
|
|
# installed clippy ONLY, so client/src-tauri was never format-
|
|
# gated at all while the engine workspace was — six files had
|
|
# quietly drifted by 5.1.1.
|
|
- name: cargo fmt
|
|
if: matrix.os == 'ubuntu-24.04'
|
|
working-directory: client/src-tauri
|
|
run: cargo fmt --all -- --check
|
|
|
|
# Bounded + retried for the same reason as publish.yml's copy: a
|
|
# stalled apt mirror once burned a whole 45-minute job budget there.
|
|
- name: Install Tauri Linux deps
|
|
if: runner.os == 'Linux'
|
|
timeout-minutes: 12
|
|
run: |
|
|
set -euo pipefail
|
|
# apt has NO default network timeout: if a mirror stalls mid-fetch it
|
|
# waits forever. That is exactly what sank the v5.3.3 publish — the
|
|
# Azure mirror went quiet after "Ign: .../noble-updates/main amd64
|
|
# Packages" and the step sat there for 45 minutes until the job
|
|
# timeout killed it, skipping create-release. Acquire::*::Timeout is
|
|
# the part that actually fixes it; the retry loop is only useful
|
|
# because of it, since without a timeout every attempt would hang
|
|
# the same way.
|
|
apt_retry() {
|
|
for attempt in 1 2 3; do
|
|
if sudo DEBIAN_FRONTEND=noninteractive apt-get \
|
|
-o Acquire::Retries=3 \
|
|
-o Acquire::http::Timeout=20 \
|
|
-o Acquire::https::Timeout=20 \
|
|
"$@"; then
|
|
return 0
|
|
fi
|
|
echo "apt attempt $attempt failed; retrying in 15s" >&2
|
|
sleep 15
|
|
done
|
|
echo "apt failed after 3 attempts: apt-get $*" >&2
|
|
return 1
|
|
}
|
|
apt_retry update
|
|
apt_retry install -y \
|
|
libgtk-3-dev \
|
|
libwebkit2gtk-4.1-dev \
|
|
libappindicator3-dev \
|
|
librsvg2-dev \
|
|
libsoup-3.0-dev \
|
|
libjavascriptcoregtk-4.1-dev \
|
|
patchelf \
|
|
build-essential \
|
|
clang \
|
|
libclang-dev
|
|
|
|
# Restored on every run; saved (under a new key per run, so it stays current) only from
|
|
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
|
|
- name: Restore cargo cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
client/src-tauri/target
|
|
key: ${{ runner.os }}-desktop-${{ matrix.os }}-${{ hashFiles('client/src-tauri/Cargo.lock') }}-${{ github.run_id }}
|
|
restore-keys: |
|
|
${{ runner.os }}-desktop-${{ matrix.os }}-${{ hashFiles('client/src-tauri/Cargo.lock') }}-
|
|
${{ runner.os }}-desktop-${{ matrix.os }}-
|
|
|
|
# tauri.conf.json bundles two out-of-tree resources: the PS5
|
|
# payload (ps5upload.elf.gz) and the engine sidecar binary.
|
|
# cargo check parses those resource paths and errors out if
|
|
# anything is missing, long before any code compiles. Stub the
|
|
# payload .gz and the engine binary (see the next step).
|
|
#
|
|
# The stub is a *fake ELF* (ELF magic + 200 KB of zeros, gzipped),
|
|
# not an empty gzip: `cargo test` runs probes.rs::
|
|
# embedded_payload_decompresses_to_elf, which gunzips the embedded
|
|
# payload and asserts ELF magic + >100 KB. That test guards the
|
|
# "Send payload" decode path (an empty stub once shipped on mobile
|
|
# and broke it). The real ~1 MB payload is built + embedded by the
|
|
# `payload` job here and by publish.yml for releases; this fake one
|
|
# only has to satisfy the decode plumbing.
|
|
- name: Stub payload resource
|
|
shell: bash
|
|
run: |
|
|
mkdir -p payload
|
|
{ printf '\x7fELF'; head -c 204800 /dev/zero; } > payload/ps5upload.elf
|
|
gzip -nf payload/ps5upload.elf # -> payload/ps5upload.elf.gz
|
|
|
|
# The desktop shell only include_bytes! the engine binary (it is extracted and started at
|
|
# run time, which no test here does), so a placeholder at the same path is enough. The real
|
|
# engine is built and checked by the rust-engine jobs; releases build it in publish.yml.
|
|
# Building it here cost 4-9 min per OS.
|
|
- name: Stub engine sidecar (needed as a Tauri resource)
|
|
shell: bash
|
|
run: |
|
|
mkdir -p engine/target/release
|
|
exe=engine/target/release/ps5upload-engine
|
|
[ "$RUNNER_OS" = "Windows" ] && exe="$exe.exe"
|
|
printf 'engine stub for CI\n' > "$exe"
|
|
|
|
# No separate `cargo check --all-targets`: clippy --all-targets type-checks the same code.
|
|
- name: cargo clippy
|
|
working-directory: client/src-tauri
|
|
run: cargo clippy --all-targets -- -D warnings
|
|
|
|
- name: cargo test
|
|
working-directory: client/src-tauri
|
|
env:
|
|
RUST_BACKTRACE: 1
|
|
run: cargo test
|
|
|
|
- name: Save cargo cache
|
|
if: github.ref == 'refs/heads/main'
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
client/src-tauri/target
|
|
key: ${{ runner.os }}-desktop-${{ matrix.os }}-${{ hashFiles('client/src-tauri/Cargo.lock') }}-${{ github.run_id }}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Client frontend — TypeScript typecheck, ESLint, Vitest, Vite build.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
client-frontend:
|
|
name: client (ts + vite)
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 15
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version: 22
|
|
cache: npm
|
|
cache-dependency-path: client/package-lock.json
|
|
|
|
- name: Install deps
|
|
working-directory: client
|
|
run: npm ci --no-audit --no-fund
|
|
|
|
- name: TypeScript typecheck
|
|
working-directory: client
|
|
run: npm run typecheck
|
|
|
|
- name: ESLint
|
|
working-directory: client
|
|
run: npm run lint
|
|
|
|
- name: Vitest
|
|
working-directory: client
|
|
run: npm test
|
|
|
|
# i18n coverage gate. validate-repo runs this locally and
|
|
# TESTING.md elevates it to a release gate; CI was the missing
|
|
# link. Static command, no event-derived input. Reviewed for
|
|
# GitHub Actions injection risk: safe.
|
|
- name: i18n coverage
|
|
run: npm run i18n:check
|
|
|
|
- name: Vite production build
|
|
working-directory: client
|
|
run: npm run build:vite
|
|
|
|
- name: Install Playwright Chromium
|
|
working-directory: client
|
|
run: npx playwright install --with-deps chromium
|
|
|
|
- name: Browser workflow smoke test
|
|
working-directory: client
|
|
run: npm run test:e2e
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Repo scripts and hygiene. Runs the same parser/audit layer exposed by
|
|
# `make lint-scripts` / `npm run scripts:check`.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
repo-scripts:
|
|
name: repo scripts
|
|
timeout-minutes: 10
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-24.04, macos-14, windows-2022]
|
|
runs-on: ${{ matrix.os }}
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version: 22
|
|
|
|
- name: Check script syntax
|
|
run: npm run scripts:check
|
|
|
|
- name: Audit scripts
|
|
# Strict mode: fail CI if any tracked script/test utility
|
|
# has zero references AND isn't on the manualEntrypoints
|
|
# allowlist. Previously this only printed warnings, so a
|
|
# renamed-without-callers script silently slipped through.
|
|
# Static command, no event-derived input.
|
|
run: node scripts/audit-scripts.mjs --strict
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Coverage reports. This is a reporting job, not the primary gate:
|
|
# rust-engine and client-frontend above fail fast on correctness.
|
|
# Artifacts contain HTML + lcov for trend tracking and local inspection.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
coverage:
|
|
name: coverage reports
|
|
# A report, not a gate (pr-gate does not need it): ~35 min of instrumented tests is not worth
|
|
# holding a runner on every PR. Pushes to main and manual runs still produce it.
|
|
if: github.event_name != 'pull_request'
|
|
runs-on: ubuntu-24.04
|
|
# `continue-on-error` below does NOT cover the timeout: a job that hits
|
|
# `timeout-minutes` is reported as *cancelled*, which cancels the whole
|
|
# run and leaves main red over a job that is explicitly not a gate. It
|
|
# reached 20m17s on 2026-09-16 against a 16m02s green run the day
|
|
# before, so the headroom here is deliberate — keep it well clear of
|
|
# the real runtime as the test suite grows. On 2026-10-06 one test pass took
|
|
# ~34 min under instrumentation (scripts/coverage.mjs now runs the suite once).
|
|
timeout-minutes: 60
|
|
# Reporting job — not a gate. cargo-llvm-cov occasionally trails
|
|
# rustc's profraw format version (we hit "raw profile version
|
|
# mismatch: ... expected version = 9" when rustc bumped to v10),
|
|
# and we don't want a coverage tool's release cadence to block
|
|
# PRs on unrelated correctness work. The artifact is for trend
|
|
# inspection, not enforcement.
|
|
continue-on-error: true
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version: 22
|
|
cache: npm
|
|
cache-dependency-path: client/package-lock.json
|
|
|
|
- name: Install client deps
|
|
working-directory: client
|
|
run: npm ci --no-audit --no-fund
|
|
|
|
- name: Install Rust stable
|
|
uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
components: llvm-tools-preview
|
|
|
|
- name: Install cargo-llvm-cov
|
|
uses: taiki-e/install-action@cargo-llvm-cov
|
|
|
|
- name: Generate coverage
|
|
run: npm run coverage
|
|
|
|
- name: Upload coverage artifact
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: coverage-reports
|
|
path: |
|
|
coverage/
|
|
client/coverage/
|
|
retention-days: 14
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Payload ELF — compile against the ps5-payload-sdk so we catch any
|
|
# breakage to the C code. Doesn't upload artifacts; publish.yml does
|
|
# that on tagged runs. Keeps PR feedback loop tight.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
payload:
|
|
name: payload (ELF)
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 20
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- name: Install payload toolchain
|
|
timeout-minutes: 12
|
|
run: |
|
|
set -euo pipefail
|
|
# apt has NO default network timeout: if a mirror stalls mid-fetch it
|
|
# waits forever. That is exactly what sank the v5.3.3 publish — the
|
|
# Azure mirror went quiet after "Ign: .../noble-updates/main amd64
|
|
# Packages" and the step sat there for 45 minutes until the job
|
|
# timeout killed it, skipping create-release. Acquire::*::Timeout is
|
|
# the part that actually fixes it; the retry loop is only useful
|
|
# because of it, since without a timeout every attempt would hang
|
|
# the same way.
|
|
apt_retry() {
|
|
for attempt in 1 2 3; do
|
|
if sudo DEBIAN_FRONTEND=noninteractive apt-get \
|
|
-o Acquire::Retries=3 \
|
|
-o Acquire::http::Timeout=20 \
|
|
-o Acquire::https::Timeout=20 \
|
|
"$@"; then
|
|
return 0
|
|
fi
|
|
echo "apt attempt $attempt failed; retrying in 15s" >&2
|
|
sleep 15
|
|
done
|
|
echo "apt failed after 3 attempts: apt-get $*" >&2
|
|
return 1
|
|
}
|
|
apt_retry update
|
|
# Add LLVM apt repo for llvm-22 (not in Ubuntu 24.04 default repos)
|
|
curl -fsSL --retry 3 --retry-delay 5 https://apt.llvm.org/llvm.sh | sudo bash -s -- 22
|
|
apt_retry install -y clang-22 llvm-22-dev lld-22 unzip python3
|
|
|
|
# The shared installer reads scripts/ps5-sdk.env, verifies the official
|
|
# release checksum, and is also used by developer bootstrap scripts and
|
|
# publish.yml. One pin therefore controls every payload build.
|
|
- name: Install pinned PS5 Payload SDK
|
|
run: sudo env PS5_SDK_INSTALL_DIR=/opt/ps5-payload-sdk bash scripts/install-ps5-sdk.sh
|
|
|
|
- name: Build payload
|
|
env:
|
|
PS5_PAYLOAD_SDK: /opt/ps5-payload-sdk
|
|
# SDK v0.42's prospero-llvm-config candidate search maxes at 21,
|
|
# so point it at the CI-installed LLVM 22 explicitly.
|
|
LLVM_CONFIG: /usr/bin/llvm-config-22
|
|
run: make payload
|
|
|
|
- name: Verify ELF
|
|
run: |
|
|
file payload/ps5upload.elf payload/installer/ps5upload-installer.elf
|
|
test -s payload/ps5upload.elf
|
|
test -s payload/ps5upload.elf.gz
|
|
test -s payload/installer/ps5upload-installer.elf
|
|
test -s payload/installer/ps5upload-installer.elf.gz
|
|
gzip -t payload/ps5upload.elf.gz payload/installer/ps5upload-installer.elf.gz
|
|
# Sanity-check the payload is a real build, not a zero-byte
|
|
# stub. Current payload is ~600 KB (runtime + takeover +
|
|
# register + blake3 SIMD backends); drop the floor to 200 KB
|
|
# so legitimate shrinkage from future refactors doesn't false-
|
|
# positive while still catching a broken/empty build.
|
|
size_bytes=$(stat -c '%s' payload/ps5upload.elf)
|
|
echo "ps5upload.elf: ${size_bytes} bytes"
|
|
if [ "$size_bytes" -lt 200000 ]; then
|
|
echo "::error::payload ELF is smaller than expected — build may have failed silently"
|
|
exit 1
|
|
fi
|
|
# AVA1: the engine stamps its key into exactly one trust slot (SPEC.md §5.1).
|
|
python3 -c "d=open('payload/ps5upload.elf','rb').read(); n=d.count(b'AVA1TRUST'); assert n==1, n"
|
|
dpi_size_bytes=$(stat -c '%s' payload/installer/ps5upload-installer.elf)
|
|
echo "ps5upload-installer.elf: ${dpi_size_bytes} bytes"
|
|
if [ "$dpi_size_bytes" -lt 50000 ]; then
|
|
echo "::error::PS5Upload installer ELF is smaller than expected"
|
|
exit 1
|
|
fi
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Android APK (Tauri mobile). Verifies the whole mobile pipeline builds
|
|
# end-to-end: the engine linked in-process, the payload embedded, the
|
|
# Tauri lib cross-compiled to aarch64-linux-android, and Gradle
|
|
# assembling an installable debug APK. Uses the same fake-ELF payload
|
|
# stub as rust-desktop — this is a build-verification job, not a
|
|
# release. publish.yml builds the real, signed APK with the actual
|
|
# payload. The debug APK is uploaded as an artifact for manual testing.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
android:
|
|
name: android (apk build)
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 40
|
|
steps:
|
|
- uses: actions/checkout@v7
|
|
|
|
- uses: actions/setup-node@v7
|
|
with:
|
|
node-version: 22
|
|
cache: npm
|
|
cache-dependency-path: client/package-lock.json
|
|
|
|
- name: Setup JDK 17
|
|
uses: actions/setup-java@v6
|
|
with:
|
|
distribution: temurin
|
|
java-version: "17"
|
|
|
|
- name: Install Rust + Android targets
|
|
uses: dtolnay/rust-toolchain@stable
|
|
with:
|
|
targets: aarch64-linux-android,armv7-linux-androideabi
|
|
|
|
# Restored on every run; saved (under a new key per run, so it stays current) only from
|
|
# main, where later runs can restore it. A PR's own cache would be visible to that PR only.
|
|
- name: Restore cargo cache
|
|
uses: actions/cache/restore@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
client/src-tauri/target
|
|
key: ${{ runner.os }}-android-${{ hashFiles('client/src-tauri/Cargo.lock') }}-${{ github.run_id }}
|
|
restore-keys: |
|
|
${{ runner.os }}-android-${{ hashFiles('client/src-tauri/Cargo.lock') }}-
|
|
${{ runner.os }}-android-
|
|
|
|
# Gradle's dependency cache.
|
|
#
|
|
# v5.4.5's CI failed here: Maven Central answered 403 for
|
|
# kotlin-stdlib and Gradle could not configure the project, so the
|
|
# build died before compiling anything of ours. Nothing was wrong
|
|
# with the code -- the publish workflow built the same commit
|
|
# minutes later.
|
|
#
|
|
# gen/android is scaffolded fresh every run, so the Gradle project
|
|
# is new each time, but ~/.gradle/caches persists across runs when
|
|
# cached. With the artifacts already local, a transient Maven
|
|
# outage stops being a build failure.
|
|
#
|
|
# Keyed loosely on purpose: the generated project pins its own
|
|
# versions, so any recent cache is a useful starting point and a
|
|
# miss only costs a download.
|
|
- name: Cache Gradle dependencies
|
|
uses: actions/cache@v6
|
|
with:
|
|
path: |
|
|
~/.gradle/caches
|
|
~/.gradle/wrapper
|
|
key: ${{ runner.os }}-android-gradle-${{ hashFiles('client/src-tauri/tauri.conf.json') }}
|
|
restore-keys: ${{ runner.os }}-android-gradle-
|
|
|
|
- name: Install client deps
|
|
working-directory: client
|
|
run: npm ci --no-audit --no-fund
|
|
|
|
# Fake-ELF payload stub (see rust-desktop's note): satisfies the
|
|
# embedded-payload decode + this build without the real ~1 MB ELF.
|
|
- name: Stub payload resource
|
|
shell: bash
|
|
run: |
|
|
mkdir -p payload
|
|
{ printf '\x7fELF'; head -c 204800 /dev/zero; } > payload/ps5upload.elf
|
|
gzip -nf payload/ps5upload.elf
|
|
|
|
# Pin the NDK to a known-good major version rather than blindly
|
|
# taking whatever the runner image ships. An NDK rotation on the
|
|
# runner image can silently break the build (new clang warnings →
|
|
# errors, libc symbol changes, etc.). If the pinned major is absent
|
|
# we fall back to the newest available and warn.
|
|
- name: Resolve NDK
|
|
shell: bash
|
|
run: |
|
|
# Preferred NDK major (r27 = NDK 27.x). Update when CI is
|
|
# validated against a newer one.
|
|
PREFERRED_MAJOR="27"
|
|
ndk=""
|
|
for candidate in \
|
|
"$ANDROID_HOME/ndk/${PREFERRED_MAJOR}."* \
|
|
"$ANDROID_HOME/ndk/"*; do
|
|
if [ -d "$candidate" ]; then
|
|
ndk="$candidate"
|
|
break
|
|
fi
|
|
done
|
|
if [ -z "$ndk" ]; then
|
|
echo "::error::no NDK found under $ANDROID_HOME/ndk"
|
|
exit 1
|
|
fi
|
|
# Warn if we couldn't get the preferred major.
|
|
case "$(basename "$ndk")" in
|
|
"${PREFERRED_MAJOR}"*) ;;
|
|
*) echo "::warning::preferred NDK r${PREFERRED_MAJOR} not found, using $(basename "$ndk")" ;;
|
|
esac
|
|
echo "NDK_HOME=$ndk" >> "$GITHUB_ENV"
|
|
echo "Using NDK: $ndk"
|
|
|
|
- name: Scaffold Android project (gen/ is gitignored)
|
|
working-directory: client
|
|
run: npx tauri android init
|
|
|
|
# `android init` seeds placeholder launcher icons; re-apply ours so
|
|
# the Android app icon matches the desktop app icon.
|
|
- name: Apply app icon
|
|
working-directory: client
|
|
run: npx tauri icon src-tauri/icons/sources/macos_squircle_1024.png
|
|
|
|
# Keep the manifest patch in the build-verification path too, so a
|
|
# regression in android-postinit-patch.sh is caught by CI (release
|
|
# parity; the loopback + backup config is harmless for debug).
|
|
- name: Patch manifest (loopback cleartext + backup rules)
|
|
run: bash scripts/release/android-postinit-patch.sh
|
|
|
|
- name: Build debug APK (arm64 + armv7)
|
|
working-directory: client
|
|
run: npx tauri android build --debug --apk --target aarch64 --target armv7
|
|
|
|
- name: Upload debug APK
|
|
uses: actions/upload-artifact@v7
|
|
with:
|
|
name: ps5upload-android-debug-apk
|
|
path: client/src-tauri/gen/android/app/build/outputs/apk/**/*.apk
|
|
if-no-files-found: error
|
|
retention-days: 7
|
|
|
|
- name: Save cargo cache
|
|
if: github.ref == 'refs/heads/main'
|
|
uses: actions/cache/save@v6
|
|
with:
|
|
path: |
|
|
~/.cargo/registry
|
|
~/.cargo/git
|
|
client/src-tauri/target
|
|
key: ${{ runner.os }}-android-${{ hashFiles('client/src-tauri/Cargo.lock') }}-${{ github.run_id }}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# Aggregate gate. One stable check that turns green only when every
|
|
# required job above succeeded. Branch protection on `main` requires
|
|
# THIS single context ("PR gate") instead of each matrix job by name,
|
|
# so adding/removing a matrix leg never silently drops it from the gate.
|
|
#
|
|
# `coverage` is deliberately excluded — it's continue-on-error (a
|
|
# reporting job, not a correctness gate), so a flaky coverage tool must
|
|
# not block a PR.
|
|
#
|
|
# `if: always()` is load-bearing: without it a FAILED dependency would
|
|
# SKIP this job, and GitHub counts a skipped required check as passing —
|
|
# letting a broken PR merge. With always() + the result check below, the
|
|
# gate runs regardless and fails when any dependency didn't succeed.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
pr-gate:
|
|
name: PR gate
|
|
if: always()
|
|
needs:
|
|
- version-sync
|
|
- rust-engine
|
|
- rust-engine-tests
|
|
- rust-ctest
|
|
- ava1
|
|
- ava1-ctest-sanitize
|
|
- rust-engine-targets
|
|
- rust-desktop
|
|
- client-frontend
|
|
- repo-scripts
|
|
- payload
|
|
- android
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
steps:
|
|
- name: Require every gating job to have succeeded
|
|
run: |
|
|
results="${{ join(needs.*.result, ' ') }}"
|
|
echo "dependency results: $results"
|
|
for r in $results; do
|
|
if [ "$r" != "success" ]; then
|
|
echo "::error::a required CI job did not succeed (saw '$r') — PR is not mergeable"
|
|
exit 1
|
|
fi
|
|
done
|
|
echo "✓ all required CI jobs succeeded"
|