Files
ps5upload/.github/workflows/engine-ci.yml
T
phantomptrandClaude Opus 5.5 7199b2fae2 Remove unused repo files and stale FTX2 doc notes
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
2026-10-10 06:24:09 -07:00

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"