Skip to content

docs: specs and a user guide for the v2.3.6 analysis tools #1228

docs: specs and a user guide for the v2.3.6 analysis tools

docs: specs and a user guide for the v2.3.6 analysis tools #1228

Workflow file for this run

name: CI
# The workflow ALWAYS triggers (no `paths-ignore`), so the required `CI success`
# check is always reported. A required status check that never runs leaves a PR's
# merge gate stuck "pending" forever — exactly what a docs-only PR would hit if
# `paths-ignore` skipped the whole run. Instead the `changes` job (dorny/paths-
# filter) decides whether any BUILD-affecting file changed; if a change touches
# only `**/*.md` / `**/*.txt` (the bulk of the docs/ + to-dos/ + ref-docs/ trees),
# screenshots/, LICENSE*, NOTICE, .gitignore, or .codegraph/, every heavy job is
# skipped and `CI success` passes trivially, so the doc-only PR can still merge.
# (This mirrors the old `paths-ignore` set exactly — a non-md/txt asset placed
# under docs/ etc., e.g. a tarball, still runs full CI, which is intentional.)
# This is the branch-protection-safe replacement for the old `paths-ignore`
# (which was fine only while `main` had no required checks).
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
# GitHub merge queue. When the queue is enabled in repo settings + the
# `CI success` check is required by branch protection, a PR entering the queue
# triggers this with the FULL cross-platform matrix (see the `setup` job) — so
# nothing merges without macOS+Windows green, even though per-PR runs are
# ubuntu-only. Inert until the queue is enabled (no `merge_group` events fire).
merge_group:
# Weekly drift safety-net. The cost optimisation below runs the FULL suite
# (release-mode `test-roms`, the macOS/Windows matrix, `bench`) only on
# release-class events — not on every intermediate feature PR — so a
# regression merged between releases could otherwise sit on `main` unproven.
# This Monday 07:00 UTC cron re-runs the full suite on `main` so any drift is
# caught within a week regardless of release cadence. `workflow_dispatch`
# covers an on-demand full run.
schedule:
- cron: "0 7 * * 1"
# Cancel superseded runs on a PR branch (rapid pushes), but let every `main`
# commit + merge-queue run finish so the default branch's history (and the
# queue's gate) stays fully checked.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# CI builds clean, so full debuginfo (the dev/test default `debug = 2`) only
# slows linking and bloats `target/` (worse cache hits). `line-tables-only`
# keeps panic/backtrace `file:line` — so failing-test diagnostics stay
# readable with RUST_BACKTRACE=1 — while cutting debug compile time ~20-40%.
# Set via `CARGO_PROFILE_*` (a stable, per-profile cache-key contributor) NOT
# `RUSTFLAGS=-Cdebuginfo=0`, which would bust the rust-cache key + force a
# full dependency rebuild. The release profile (test-roms / bench) already
# defaults to `debug = 0`, so this only touches the debug-compiling jobs.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
# Least-privilege default for the whole workflow: every job here only builds /
# tests / lints and reads the checked-out tree, so the default GITHUB_TOKEN is
# narrowed to read-only. Resolves the CodeQL `actions/missing-workflow-permissions`
# alerts (one was raised per job for the absent permissions block); a job needing
# more would override with its own `permissions:` key.
permissions:
contents: read
jobs:
# Decide whether any build-affecting file changed. Because the workflow always
# runs (so `CI success` always reports for branch protection), this is what
# actually skips the heavy jobs on a docs-only change: they each gate on
# `needs.changes.outputs.code == 'true'`. The `code` filter is the inverse of
# the doc set the old `paths-ignore` listed (match everything, then exclude
# docs — picomatch/gitignore precedence). For events with no diff base (manual
# dispatch, merge queue) paths-filter reports every filter as changed, so they
# run the full suite.
changes:
name: detect code changes
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
# Full history: on `push`, dorny/paths-filter diffs against the before-SHA
# using local git, so the default shallow clone (fetch-depth: 1) can miss
# the base commit. (On `pull_request` it uses the GitHub API and needs no
# history.) Without this the push-event filter falls back to "everything
# changed" — safe, but it loses the docs-only skip on direct main pushes.
#
# This is the one job in the credential-hardening sweep (#318) where the
# change touches the git layer at all: paths-filter reads local git on
# `push` events. It needs no authentication — `fetch-depth: 0` means the
# base commit is already local, and the `pull_request` path goes through
# the GitHub API with its own token — so dropping the persisted credential
# is expected to be inert. Failure mode if that is ever wrong is benign
# (the filter reports "everything changed" and the full suite runs), but
# it would silently cost the docs-only skip the comment above describes.
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- '**'
- '!**/*.md'
- '!**/*.txt'
- '!screenshots/**'
- '!LICENSE*'
- '!NOTICE'
- '!.gitignore'
- '!.codegraph/**'
# fmt + clippy + rustdoc share one runner + one compile of the workspace's
# dependency graph (clippy and rustdoc differ only in the final pass), so
# merging them removes two redundant dependency compiles and two runner
# spin-ups vs three separate jobs. fmt runs first (it compiles nothing), so
# a formatting slip still fails in seconds.
#
# Pinned to the project toolchain (rust-toolchain.toml = 1.96.0), NOT a
# floating `stable`: clippy adds lints every release, so `stable` makes the
# lint gate drift and break on a toolchain bump. Pinning ALSO makes the
# rustdoc gate match local `cargo doc` exactly (rust-toolchain.toml pins the
# local compiler to 1.96 too) — the stable-vs-1.96 mismatch previously let a
# broken-intra-doc-link slip past local and only fail in CI.
#
# Compute ONE "full-run" flag that gates both the OS matrix and the expensive
# jobs, then reuse it everywhere. The cost model: a regular (non-release) PR is
# a LIGHT run — fast, essential feedback only (fmt/clippy/rustdoc, the ubuntu
# `test`, no_std, wasm-clippy); every other event is a FULL run that adds the
# macOS/Windows matrix legs, the release-mode `test-roms` accuracy battery, and
# the `bench` gate.
#
# FULL when: push-to-`main` (validates every merge), the merge queue, manual
# dispatch, the weekly `schedule` drift-net, OR a `release/*` PR (so a release
# is fully proven before it is cut). LIGHT only on a regular feature PR — that
# is where the iteration (and thus the runner-minute burn) happens: each PR gets
# many pushes, so keeping the ~20-min release `test-roms` + the 10x-billed macOS
# / 2x Windows legs off every PR push is the bulk of the savings. The free arm64
# leg still runs on every PR (aarch64 alignment/SIMD/atomics/fp coverage), and
# the post-merge push + release PR + weekly cron still give full cross-platform
# + accuracy coverage before anything ships.
setup:
name: select matrix
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
outputs:
os: ${{ steps.pick.outputs.os }}
full: ${{ steps.pick.outputs.full }}
steps:
- id: pick
shell: bash
# Pass the event/ref through `env` (NOT direct `${{ }}` interpolation into
# the script) so an attacker-chosen branch name can't inject shell — the
# `actions/script-injection` pattern CodeQL flags.
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REF: ${{ github.head_ref }}
run: |
# LIGHT only for a regular PR (head branch not release/*); FULL otherwise.
if [ "$EVENT_NAME" = "pull_request" ] && [[ "$HEAD_REF" != release/* ]]; then
echo 'full=false' >> "$GITHUB_OUTPUT"
echo 'os=["ubuntu-latest", "ubuntu-24.04-arm"]' >> "$GITHUB_OUTPUT"
else
echo 'full=true' >> "$GITHUB_OUTPUT"
echo 'os=["ubuntu-latest", "ubuntu-24.04-arm", "macos-latest", "windows-latest"]' >> "$GITHUB_OUTPUT"
fi
lint:
name: fmt + clippy + rustdoc
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
env:
RUSTDOCFLAGS: -D warnings
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
with:
components: rustfmt,clippy
- name: rustfmt
run: cargo fmt --all -- --check
- name: clippy (-D warnings)
run: cargo clippy --workspace --all-targets -- -D warnings
- name: rustdoc (-D warnings)
run: cargo doc --workspace --no-deps
# Feature-gated clippy combos CLAUDE.md mandates. These previously ran
# ONLY locally, so a feature-gated lint regression (e.g. a `cfg`-stripped
# autofix breaking the `retroachievements` build) could land CI-green.
# Run them serially on this one already-warm runner (shared dependency
# compile) rather than a matrix (which multiplies cache storage). NOT
# `--all-features`: `scripting` (mlua) + `script-wasm` (piccolo) are
# mutually-exclusive backends that cannot co-resolve.
- name: clippy (scripting — native Lua engine)
run: cargo clippy -p rustynes-frontend --all-targets --features scripting -- -D warnings
- name: clippy (scripting + hd-pack)
run: cargo clippy -p rustynes-frontend --all-targets --features scripting,hd-pack -- -D warnings
- name: clippy (retroachievements — rcheevos FFI)
run: cargo clippy -p rustynes-frontend --all-targets --features retroachievements -- -D warnings
# The `full` umbrella (retroachievements + scripting + script-ipc + hd-pack +
# debug-hooks + av-record) is the catch-all that lints the native features the
# three subset combos above DON'T reach on their own — notably `script-ipc` and
# `av-record`. One extra compile on the already-warm runner closes the remaining
# cfg-gated-code coverage gap (#11a) without a per-feature matrix.
- name: clippy (full — every native feature)
run: cargo clippy -p rustynes-frontend --all-targets --features full -- -D warnings
# `rustynes-mappers` with `mapper-audio` OFF. Every combo above turns
# features ON; this is the only gate that compiles a feature OUT, and the
# subtraction is where the rot happens: this configuration was outright
# BROKEN — a hard `E0599`, because `Namco163Audio` was missing the
# feature-off `clock()` shim that the NSF expansion router calls
# unconditionally — and nothing noticed, because nothing built it. The
# `no_std` job below is `-p rustynes-core`, which keeps `mapper-audio` on.
#
# The feature's contract (see the crate manifest) is that with audio off
# the register decoders stay live — so save states from an audio-enabled
# build still load — while the oscillators freeze. That asymmetry is
# exactly what a compile-only gate protects.
- name: clippy (mappers — mapper-audio compiled OUT)
run: cargo clippy -p rustynes-mappers --no-default-features --all-targets -- -D warnings
# Cross-platform behaviour: full `cargo test` on stable across the three
# shipped OSes. The previous ubuntu / 1.96 MSRV *test* entry is dropped — the
# `lint` job above already compiles the whole workspace (`clippy
# --all-targets`) on 1.96, which IS the MSRV compile gate, and the core's
# determinism makes "tests pass on 1.96" equivalent to "tests pass on stable".
test:
name: test (${{ matrix.os }})
# Gate the matrix on the fast fmt+clippy+rustdoc job (a lint slip fails in
# ~seconds — no point spinning up OS runners until it's green) and take the
# OS list from `setup` (ubuntu-only on PRs; full matrix on push / merge
# queue / dispatch). `changes` keeps the whole chain off a docs-only run.
needs: [changes, lint, setup]
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON(needs.setup.outputs.os) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
with:
cache-key: test
# NOTE: cargo-nextest is deliberately NOT adopted (§11e closed WONT-FIX).
# nextest builds its test graph by running `cargo metadata --all-features`,
# but this workspace cannot resolve `--all-features`: `scripting` (mlua) and
# `script-wasm` (piccolo) are mutually-exclusive rustynes-script backends
# (the same reason CLAUDE.md forbids `--all-features` everywhere). nextest
# offers no override for that internal metadata call, so it errors before
# running a single test (`cargo metadata --all-features` exits 101). Plain
# `cargo test` with explicit features stays the runner.
- run: cargo test --workspace
test-roms:
name: test (test-roms feature)
# The SLOWEST job (release-mode compile + the heavy CPU / AccuracyCoin ROM
# batteries, ~20 min). FULL-run only: it is skipped on a regular feature PR
# and runs on push-to-`main` (every merge is accuracy-validated), the merge
# queue, dispatch, the weekly cron, and `release/*` PRs (so a release is
# proven before it is cut). This is the single biggest per-PR time/cost
# saving. Also gated on the fast lint job — don't pay the release compile
# when fmt/clippy already failed.
needs: [changes, lint, setup]
if: ${{ needs.changes.outputs.code == 'true' && needs.setup.outputs.full == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
with:
cache-key: test-roms
# A failure here is at the TEST stage (a ROM assertion), so keep the
# expensive release-mode dependency compile cached rather than
# discarding it.
cache-on-failure: "true"
# Built in `--release`: the heavy CPU test ROMs (instr_test-v3,
# blargg_nes_cpu_test5, the nes_instr_test singles) + the AccuracyCoin
# 7200-frame battery are ~10x slower in debug. The emulation core is
# deterministic, so release vs debug produce byte-identical
# framebuffer/audio/cycle hashes — the ROM suites (AccuracyCoin 100%,
# the 60-ROM oracle) assert the same values either way. Debug-assertion
# coverage of the core is NOT lost: the OS-matrix `test` job runs the
# workspace tests in debug.
- run: cargo test --workspace --release --features test-roms
no_std:
name: no_std build (thumbv7em-none-eabihf)
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
with:
targets: thumbv7em-none-eabihf
apt: "false"
cache-key: no_std
# Proves the chip stack (rustynes-cpu / rustynes-ppu / rustynes-apu / rustynes-mappers /
# rustynes-core) is genuinely no_std-capable. `rustynes-frontend` uses wgpu /
# winit / cpal and stays std-only; we deliberately don't build the
# whole workspace here.
- run: cargo build -p rustynes-core --target thumbv7em-none-eabihf --no-default-features
wasm:
name: wasm32 build + clippy
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: ./.github/actions/rust-setup
with:
targets: wasm32-unknown-unknown
components: clippy
apt: "false"
cache-key: wasm32
# The wasm32 frontend modules (wasm.rs / wasm_winit.rs / wasm_audio.rs)
# only compile on `cfg(target_arch = "wasm32")`, so the native `lint`
# job never sees them — this job is the only PR-time signal for that
# code. Both `#[wasm_bindgen(start)]` flavours are checked (exactly one
# is compiled per build).
- name: clippy (wasm-winit — default, unified winit/wgpu/egui)
run: cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --lib --bins -- -D warnings
- name: clippy (wasm-canvas — lightweight embed mode)
run: cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --no-default-features --features wasm-canvas --lib --bins -- -D warnings
# `browser-cheevos` (casual-mode RetroAchievements wasm scaffolding, ADR 0015)
# is wasm-only, so the native lint job never sees it — this is its only PR-time
# clippy signal (#11a's remaining named gap; `script-ipc` is covered by the
# native `full` combo in the lint job).
- name: clippy (browser-cheevos — casual-mode RA wasm scaffolding)
run: cargo clippy -p rustynes-frontend --target wasm32-unknown-unknown --features browser-cheevos --lib --bins -- -D warnings
libretro-cross:
name: libretro cross-compile (buildbot targets)
needs: changes
if: ${{ needs.changes.outputs.code == 'true' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Only the buildbot triples a LINUX runner can model faithfully.
#
# `rust-libretro-sys` runs bindgen, and bindgen needs headers for the
# target it parses `libretro.h` under. Four of the buildbot ABI
# families can supply them here; the Apple family cannot:
# * MinGW-Windows — no glibc involved at all (and `.cargo/config.toml`
# points its bindgen at the MSVC triple), so it parses cleanly.
# * Linux-aarch64 (glibc) — needs the aarch64 glibc HEADERS (not a
# linker). bindgen parses `libretro.h` under `--target aarch64`, and
# glibc's `stdint.h` pulls in `bits/libc-header-start.h` — an
# arch-split header a stock x86_64 runner ships only for x86_64, so
# clang dies exactly like the Apple note below (`bits/libc-header-
# start.h` not found). The step below installs `gcc-aarch64-linux-gnu`
# (for its `libc6-dev-arm64-cross` headers under
# `/usr/aarch64-linux-gnu/include`) and points bindgen there via
# `--sysroot`. Still check-only: that package's cross linker / `STRIP`
# are unused here — they matter only at the buildbot's link/package
# step. This is the PR-time rehearsal of the `.gitlab-ci.yml`
# `libretro-build-linux-aarch64` job (added in #331). (An Arch dev host
# masks the need — its glibc headers are not arch-split, so a local
# `cargo check` passes without the sysroot; the Debian-multiarch runner
# does not, which is exactly the blind spot this gate exists to catch.)
# * Linux-armhf (glibc, 32-bit ARM) — identical situation to aarch64,
# wired up the same way via `gcc-arm-linux-gnueabihf` and its
# `libc6-dev-armhf-cross` headers under `/usr/arm-linux-gnueabihf`.
#
# UNLIKE the other three, this leg rehearses NO buildbot job, and
# deliberately so. The buildbot's `linux/armhf` and
# `linux/armv7-neon-hf` trees are abandoned — every artifact in them
# is dated 2020-11-17, against 2026 for `linux/aarch64` and
# `linux/x86_64` — and libretro has no active ARM32 publishing, so a
# `.gitlab-ci.yml` job would build correctly and deliver to a
# directory no user can reach through RetroArch's updater. It was
# written, then removed for exactly that reason.
#
# The leg is kept because its value is independent of libretro:
# 32-bit ARM is the only pointer-width-32 non-Windows target this
# workspace compiles, so it is the cheapest guard against a
# dependency or `usize`-assumption regression that the 64-bit legs
# cannot see. It also stays ready if ARM32 publishing ever returns.
# The Arch-host blind spot noted above applies here too, so a clean
# local `cargo check` proves nothing about this triple — only this
# runner does.
# * Android — ubuntu-latest ships an NDK, whose sysroot is wired up
# below exactly as `cargo ndk` does it on the buildbot.
# * Apple (macOS/iOS) — DELIBERATELY EXCLUDED. There is no Apple SDK
# on a Linux runner, so clang falls back to host glibc headers and
# dies on Debian multiarch (`bits/libc-header-start.h` not found);
# "fixing" that by feeding it `/usr/include/x86_64-linux-gnu` would
# generate Apple bindings from Linux headers — a lookalike, not a
# rehearsal. Doing it properly needs a macOS runner, which this repo
# reserves for full (non-PR) runs; see the `test` job's `setup`-driven
# OS matrix. The Apple legs share this crate's entire dependency
# graph with the three legs below, so the marginal coverage is small.
target:
- x86_64-pc-windows-gnu
- aarch64-unknown-linux-gnu
- armv7-unknown-linux-gnueabihf
- aarch64-linux-android
steps:
# `cargo check` runs build scripts and proc macros from the checkout — on
# a PR that is untrusted code — and `actions/checkout` otherwise leaves
# the workflow token in `.git/config` where that code could read it. No
# step here needs Git auth afterwards, so drop the credentials.
- uses: actions/checkout@v7
with:
persist-credentials: false
# No `toolchain:` input anywhere in this repo: `rust-setup` resolves it
# from `rust-toolchain.toml`, so this job rehearses the buildbot on the
# exact channel the buildbot uses.
- uses: ./.github/actions/rust-setup
with:
apt: "false"
cache-key: libretro-${{ matrix.target }}
# Point bindgen at the NDK sysroot, which is what `cargo ndk` does for us
# on the buildbot. Without it clang has no Android `stdint.h` and the
# build script panics before a single Rust line is compiled. The NDK path
# prefix matches `android.yml`'s (`$ANDROID_NDK_LATEST_HOME`, then
# `toolchains/llvm/prebuilt/linux-x86_64/...`).
- name: Point bindgen at the NDK sysroot
if: matrix.target == 'aarch64-linux-android'
run: |
echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--sysroot=$ANDROID_NDK_LATEST_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot" >> "$GITHUB_ENV"
# Provision the aarch64 glibc headers for bindgen (see the matrix note).
# `gcc-aarch64-linux-gnu` pulls `libc6-dev-arm64-cross`, which lands the
# aarch64 glibc headers under `/usr/aarch64-linux-gnu/include`; `--sysroot`
# sends clang there instead of the host's x86_64-only `/usr/include`, and
# the explicit `-isystem` guarantees the include dir is on the search path
# regardless of how the sysroot lays out `usr/include`. The cross linker in
# that package is unused — this gate is `cargo check` only.
- name: Provision the aarch64 glibc headers for bindgen
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update -qq
sudo apt-get install -yq gcc-aarch64-linux-gnu
echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_unknown_linux_gnu=--sysroot=/usr/aarch64-linux-gnu -isystem /usr/aarch64-linux-gnu/include" >> "$GITHUB_ENV"
# Provision the armhf glibc headers for bindgen, exactly as for aarch64
# above. `gcc-arm-linux-gnueabihf` pulls `libc6-dev-armhf-cross`, landing
# the 32-bit ARM glibc headers under `/usr/arm-linux-gnueabihf/include`.
# The cross linker in that package is unused here — this gate is
# `cargo check` only; the buildbot remains the authority on linking.
- name: Provision the armhf glibc headers for bindgen
if: matrix.target == 'armv7-unknown-linux-gnueabihf'
run: |
sudo apt-get update -qq
sudo apt-get install -yq gcc-arm-linux-gnueabihf
echo "BINDGEN_EXTRA_CLANG_ARGS_armv7_unknown_linux_gnueabihf=--sysroot=/usr/arm-linux-gnueabihf -isystem /usr/arm-linux-gnueabihf/include" >> "$GITHUB_ENV"
# Deliberately NOT the composite action's `targets:` input. That routes
# through `dtolnay/rust-toolchain`, which installs the target for the
# channel IT pins (stable) — while `rust-toolchain.toml` then switches
# cargo to 1.96.0, leaving the target missing on the toolchain that
# actually builds. `no_std` / `wasm` escape this only because their two
# targets are listed in `rust-toolchain.toml`. Running `rustup target
# add` from inside the checkout resolves that file first, so the target
# lands on 1.96.0 — the identical one-liner `.gitlab-ci.yml` runs, which
# keeps this job an honest rehearsal of the buildbot rather than a
# differently-configured lookalike.
- run: rustup target add ${{ matrix.target }}
# Until this job existed there was ZERO libretro coverage in GitHub
# Actions, so breakage only surfaced on libretro's own GitLab buildbot —
# where we can't push, can't re-run, and turnaround is days. Pipeline
# #91899 failed 9 of 10 jobs on problems this job reproduces in seconds:
# a missing cross target (see `rust-toolchain.toml`) and an upstream
# `rust-libretro` MinGW-ABI bug (see `.cargo/config.toml`).
#
# `check`, not `build`: neither target can LINK on a Linux runner without
# its platform SDK, and every #91899 failure was compile-time. The
# buildbot remains the authority on linking; this is the early-warning
# gate for target availability, dependency portability, and MSRV drift.
- run: cargo check --release -p rustynes-libretro --target ${{ matrix.target }}
bench:
name: frame-time regression gate
# FULL-run only (like `test-roms`): a release-mode criterion build + run is
# too costly for every PR push, and the frame-time gate is an absolute
# ceiling that only needs to hold at merge / release, not on every iteration.
needs: [changes, setup]
if: ${{ needs.changes.outputs.code == 'true' && needs.setup.outputs.full == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
# fetch-depth: 0 — the RELATIVE gate below benches the base commit in
# a worktree, which needs real history. The default depth-1 checkout
# cannot resolve it and the gate would skip on every run.
fetch-depth: 0
- uses: ./.github/actions/rust-setup
with:
apt: "false"
cache-key: bench
# The `rustynes-core` full_frame bench is chip-stack-only (no wgpu/winit),
# so no apt deps are needed.
#
# TWO gates, deliberately different in kind:
#
# 1. ABSOLUTE ceiling — headless frame production stays well under the
# 16.67 ms NTSC deadline. Never flakes, but on the ~4 ms/frame the
# core actually runs at, a change could get 2.5x slower and still
# pass. It answers "is the emulator still real-time?", not "did this
# PR make it worse".
# 2. RELATIVE A/B — builds and benches the base commit and HEAD back to
# back on THIS runner and compares them to each other. Runner-to-
# runner variance is common-mode and cancels, which is why a
# percentage gate is sound here where a cross-run one would flake
# (same technique as pgo.yml's >3% promotion bar). It answers the
# question the ceiling cannot.
#
# See scripts/bench_{regression,relative}_check.sh + docs/performance.md.
- run: ./scripts/bench_regression_check.sh
# Base ref: the PR's base-branch tip, else the push's previous head.
# Neither is present on a workflow_dispatch, and `github.event.before` is
# all-zeros for a brand-new branch — in both cases the script resolves
# nothing and SKIPs with exit 0 rather than inventing a verdict.
- name: Relative frame-time A/B (base vs HEAD, same runner)
env:
BENCH_BASE_REF: >-
${{ github.event.pull_request.base.sha || github.event.before }}
run: ./scripts/bench_relative_check.sh
# Single summary status — the ONE check to require in branch protection / the
# merge queue. It runs always (even when a needed job fails or is skipped) and
# fails iff any job it depends on failed or was cancelled. A *skipped* job
# counts as success — so an event-conditional leg (the macOS/Windows matrix on
# a PR) never blocks, AND a docs-only change (every heavy job skipped via the
# `changes` gate) still reports this check green so the PR can merge. KEEP the
# `needs:` list exhaustive: a job missing from it silently stops being gated.
ci-success:
name: CI success
if: ${{ always() }}
needs: [changes, setup, lint, test, test-roms, no_std, wasm, libretro-cross, bench]
runs-on: ubuntu-latest
steps:
- name: Fail if any required job failed or was cancelled
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: |
echo "A required CI job failed or was cancelled:"
echo '${{ toJSON(needs) }}'
exit 1
- name: All required jobs succeeded
run: echo "CI success."