Repository navigation
v2.7.4 "Pocket": the mobile apps survive what a phone does to them #1872
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] | |
| # The three defaults plus `ready_for_review`, which is what starts the | |
| # heavy jobs on a PR that was opened as a draft -- see `setup` below. | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| 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. | |
| # | |
| # `cancel-in-progress: false` on `main` has a consequence that cost a release: | |
| # pushes QUEUE behind the in-progress run, and GitHub keeps only ONE pending run | |
| # per group -- so a third push cancels the second. On the night of the v2.3.6 | |
| # cut the `lint` job (a ~4-minute job) hung at 2026-08-17 21:11 UTC. The release | |
| # commit, pushed 2026-08-18 01:56 UTC, sat pending behind it and never started; | |
| # the commit between them was cancelled | |
| # outright; `Auto Release` fired on THAT cancellation, saw a non-success | |
| # conclusion, and correctly skipped. Every PR was green, the release simply | |
| # never happened, and nothing reported an error anywhere. | |
| # | |
| # Every job therefore carries an explicit `timeout-minutes`. Without one a job | |
| # inherits GitHub's SIX HOUR default, which on a serialized `main` group is six | |
| # hours of blocked releases. | |
| # | |
| # Sizing policy, since it is NOT a single ratio: a 20-minute FLOOR for jobs that | |
| # finish in seconds (a ratio would put them under a minute, where runner startup | |
| # and a cold cache trip them for no reason), and roughly 2-3x the observed | |
| # duration for the jobs long enough for a ratio to mean something -- 30 for a | |
| # ~4-minute lint, 45 for a ~16-minute Windows leg, 60 for a ~29-minute | |
| # `test-roms`. Each job records its own observed time beside its budget. The | |
| # point is not to measure the runner; it is that six hours becomes thirty | |
| # minutes and a red X. | |
| 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-26.04 | |
| # Typical: ~10 s. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 20 | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| outputs: | |
| code: ${{ steps.filter-code.outputs.code }} | |
| accuracy: ${{ steps.filter-accuracy.outputs.accuracy }} | |
| libretro: ${{ steps.filter-targets.outputs.libretro }} | |
| nostd: ${{ steps.filter-targets.outputs.nostd }} | |
| 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 | |
| # TWO filter steps, not one, because `predicate-quantifier` is a STEP-level | |
| # input and the two filters need opposite settings. | |
| # | |
| # `code` is one positive pattern plus exclusions, so it needs `every`: | |
| # under the default `some` a file is included if it matches ANY pattern, | |
| # `'**'` matches everything, and every `!` line below it is dead. The | |
| # action's README says so outright — "under some, negation syntax is | |
| # ineffective". | |
| # | |
| # That is not theoretical. Until v2.3.9 both filters shared one step on the | |
| # default, so the docs-only skip NEVER worked: PR #416 changed exactly one | |
| # file, `AGENTS.md`, and the filter log reads | |
| # `Filter code = true / Matching files: AGENTS.md`. Every documentation PR | |
| # in this repo's history has run the full code matrix, including the ARM | |
| # cross-compiles, and two docs PRs were blocked by a flaky apt provision | |
| # for jobs that should never have been scheduled. | |
| - uses: dorny/paths-filter@v4 | |
| id: filter-code | |
| with: | |
| predicate-quantifier: every | |
| filters: | | |
| code: | |
| - '**' | |
| - '!**/*.md' | |
| - '!**/*.txt' | |
| - '!screenshots/**' | |
| - '!LICENSE*' | |
| - '!NOTICE' | |
| - '!.gitignore' | |
| - '!.codegraph/**' | |
| # `accuracy` keeps the DEFAULT quantifier, and must. Its patterns are | |
| # alternatives — a file lives in one of these trees, never in all eight — | |
| # so `every` would make it unsatisfiable and silently disable the accuracy | |
| # battery at review time, which is the gate A5 exists to add. | |
| - uses: dorny/paths-filter@v4 | |
| id: filter-accuracy | |
| with: | |
| filters: | | |
| # v2.3.9 A5 — paths that can move an accuracy or visual vector. | |
| # | |
| # `test-roms` used to be FULL-run only, so a regular feature PR never | |
| # ran the accuracy battery and a regression could not be caught on | |
| # the PR that caused it: it landed, turned `main` red, and was fixed | |
| # by a second PR. #396 (a PPU fix that legitimately moved | |
| # `visual_regression__scanline_frame_180`) and #403 (the vector | |
| # update that followed) are the worked example. | |
| # | |
| # Scoped by path rather than by event, because a vector can only move | |
| # if something that PRODUCES one changed. Measured against the last | |
| # 40 merged PRs: 11 touch these paths and 29 do not, so ~72% of PRs | |
| # still pay nothing and the cost model the full-run flag exists for | |
| # is preserved. | |
| # | |
| # `rustynes-gamedb` is in the list for a non-obvious reason: the | |
| # per-game database rewrites the iNES header on load, so it changes | |
| # what the emulator IS before a single cycle runs. That is how the | |
| # v2.3.4 Sachen defect reached users. | |
| accuracy: | |
| - 'crates/rustynes-cpu/**' | |
| - 'crates/rustynes-ppu/**' | |
| - 'crates/rustynes-apu/**' | |
| - 'crates/rustynes-mappers/**' | |
| - 'crates/rustynes-core/**' | |
| - 'crates/rustynes-gamedb/**' | |
| - 'crates/rustynes-test-harness/**' | |
| - 'tests/**' | |
| # Per-target filters (2026-09-23): a cross-build job runs on a PR only | |
| # when something it COMPILES changed. Each crate list is exactly what | |
| # `cargo tree -p <pkg> -e normal,build --target all --prefix none` reports | |
| # as a path | |
| # dependency (`--target all`, or a target-specific dependency is | |
| # invisible), and must be re-derived when a crate gains a dependency on | |
| # another workspace crate -- a crate missing from a list means a PR that | |
| # breaks that build skips the gate, and a skipped job passes `CI success`. | |
| # The shared entries (manifests, lockfile, toolchain, CI plumbing) are in | |
| # every list because they change every build. Full runs ignore these. | |
| - uses: dorny/paths-filter@v4 | |
| id: filter-targets | |
| with: | |
| filters: | | |
| shared: &shared | |
| - 'Cargo.toml' | |
| - 'Cargo.lock' | |
| - 'rust-toolchain.toml' | |
| - '.cargo/**' | |
| - '.github/workflows/ci.yml' | |
| - '.github/actions/**' | |
| - '.github/scripts/**' | |
| # rustynes-libretro -> core, cpu, ppu, apu, mappers | |
| libretro: | |
| - *shared | |
| - 'crates/rustynes-libretro/**' | |
| - 'crates/rustynes-core/**' | |
| - 'crates/rustynes-cpu/**' | |
| - 'crates/rustynes-ppu/**' | |
| - 'crates/rustynes-apu/**' | |
| - 'crates/rustynes-mappers/**' | |
| # rustynes-core, --no-default-features, thumbv7em -> cpu, ppu, apu, mappers | |
| nostd: | |
| - *shared | |
| - 'crates/rustynes-core/**' | |
| - 'crates/rustynes-cpu/**' | |
| - 'crates/rustynes-ppu/**' | |
| - 'crates/rustynes-apu/**' | |
| - 'crates/rustynes-mappers/**' | |
| # 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. | |
| # RUNNER LABELS ARE PINNED (2026-09-23): `ubuntu-26.04` / `ubuntu-26.04-arm`, | |
| # never `ubuntu-latest`, in every workflow. GitHub moves `latest` to a new | |
| # image on a date of its choosing, and announced the 26.04 move with a notice | |
| # on EVERY job (241 of them in the last 150 runs). A pinned label makes the | |
| # next OS upgrade a reviewed diff instead of a surprise, and keeps the notice | |
| # out of the logs. | |
| setup: | |
| name: select matrix | |
| needs: changes | |
| if: ${{ needs.changes.outputs.code == 'true' }} | |
| runs-on: ubuntu-26.04 | |
| # Typical: ~5 s. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 20 | |
| outputs: | |
| os: ${{ steps.pick.outputs.os }} | |
| full: ${{ steps.pick.outputs.full }} | |
| heavy: ${{ steps.pick.outputs.heavy }} | |
| 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 }} | |
| IS_DRAFT: ${{ github.event.pull_request.draft }} | |
| run: | | |
| # DRAFT GATE (2026-09-23). A PR is opened as a DRAFT while the review | |
| # bots work it: every push in that phase re-runs CI, and the review | |
| # ceremony on this repository routinely takes several pushes. On a | |
| # draft only the fast gates run (fmt/clippy/rustdoc, no_std, wasm32, | |
| # the libretro cross-compiles); `test` and `test-roms` -- about 12 and | |
| # 26 minutes, the two largest jobs in the workflow -- wait until | |
| # `gh pr ready`, whose `ready_for_review` event runs everything once, | |
| # on the code that is actually going to merge. | |
| # | |
| # `CI success` still passes on a draft, and that is safe: GitHub | |
| # refuses to merge a draft, and marking it ready starts a new run | |
| # whose `CI success` is pending until the heavy jobs finish. A merge | |
| # queue would be the native form of this, and is unavailable here -- | |
| # GitHub offers it only to organisation-owned repositories. | |
| if [ "$EVENT_NAME" = "pull_request" ] && [ "$IS_DRAFT" = "true" ]; then | |
| echo 'heavy=false' >> "$GITHUB_OUTPUT" | |
| else | |
| echo 'heavy=true' >> "$GITHUB_OUTPUT" | |
| fi | |
| # 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-26.04", "ubuntu-26.04-arm"]' >> "$GITHUB_OUTPUT" | |
| else | |
| echo 'full=true' >> "$GITHUB_OUTPUT" | |
| echo 'os=["ubuntu-26.04", "ubuntu-26.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-26.04 | |
| # Typical: ~4 min. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 30 | |
| 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 | |
| # `rustynes-cosim` is EXCLUDED from the workspace, so `--workspace` above | |
| # does not reach it. That exclusion is deliberate -- as a member it made | |
| # `cargo build --workspace` unify its trace features onto `rustynes-core`, | |
| # so the accuracy battery validated an instrumented scheduler no user runs | |
| # (see the `exclude` note in the root Cargo.toml). The cost of excluding it | |
| # is that its lints and tests need naming explicitly, which is what these | |
| # two steps are. Without them the crate would simply stop being checked, | |
| # and nothing would say so. | |
| - name: "rustfmt (excluded crate: rustynes-cosim)" | |
| run: cargo fmt --manifest-path crates/rustynes-cosim/Cargo.toml --check | |
| - name: "clippy (excluded crate: rustynes-cosim)" | |
| run: > | |
| cargo clippy --manifest-path crates/rustynes-cosim/Cargo.toml | |
| --all-targets -- -D warnings | |
| - name: "test (excluded crate: rustynes-cosim)" | |
| run: cargo test --manifest-path crates/rustynes-cosim/Cargo.toml | |
| # ... and again WITH `ppu-state-trace`, because that crate's | |
| # `state_trace_records_carry_their_cpu_cycle` test is itself gated on the | |
| # feature and so ran NOWHERE in CI. It exists to catch a field that is | |
| # present but constant -- the exact defect that shipped when the bus | |
| # stamped only one of its two call sites -- and a regression test the gate | |
| # cannot reach is the shape this whole block of steps exists to prevent. | |
| # | |
| # `fast_path_does_not_bypass_the_fetch_trace` is `#![cfg(not(...))]` out | |
| # under this feature, which compiles the fast dot path away entirely: with | |
| # no fast path to compare against the general one, that test refuses | |
| # rather than passing vacuously, and a red gate for a property the build | |
| # does not have is noise rather than signal. | |
| - name: "test (excluded crate: rustynes-cosim, ppu-state-trace)" | |
| run: > | |
| cargo test --manifest-path crates/rustynes-cosim/Cargo.toml | |
| --features ppu-state-trace | |
| # The four TRACE features, linted explicitly. `--workspace --all-targets` | |
| # covers each crate's DEFAULT feature set only, and the step above lints | |
| # the `rustynes-cosim` package while compiling `rustynes-core` and | |
| # `rustynes-ppu` as DEPENDENCIES -- where warnings are not denied. So | |
| # every `#[cfg(feature = "...-trace")]` module in the chip crates was | |
| # unlinted: v2.4.1 found six pre-existing `-D warnings` errors sitting in | |
| # `cpu_boot_trace.rs` and `irq_trace.rs` the moment anything reached them. | |
| # Named one per feature rather than combined, so a failure says WHICH. | |
| # | |
| # `-p rustynes-core` is deliberate and it DOES lint `rustynes-ppu` too -- | |
| # a review of #450 called this ineffective on the general rule that clippy | |
| # lints only the selected package. That rule does not hold for a workspace | |
| # member: cargo runs clippy-driver through RUSTC_WORKSPACE_WRAPPER, which | |
| # covers every workspace member in the graph, and `-D warnings` reaches | |
| # them. Measured, not argued -- reverting one `const fn` fix in | |
| # `rustynes-ppu/src/state_trace.rs` makes this exact step fail with | |
| # "could not compile `rustynes-ppu`", and it is clean again when restored. | |
| # `-p rustynes-core` is therefore the BROADER target, since it also lints | |
| # the core's own feature-gated code. Do not narrow it to `-p rustynes-ppu`. | |
| - name: clippy (cpu-boot-trace) | |
| run: cargo clippy -p rustynes-core --all-targets --features cpu-boot-trace -- -D warnings | |
| - name: clippy (irq-timing-trace) | |
| run: cargo clippy -p rustynes-core --all-targets --features irq-timing-trace -- -D warnings | |
| - name: clippy (ppu-state-trace) | |
| run: cargo clippy -p rustynes-core --all-targets --features ppu-state-trace -- -D warnings | |
| - name: clippy (ppu-fetch-trace) | |
| run: cargo clippy -p rustynes-core --all-targets --features ppu-fetch-trace -- -D warnings | |
| # The HARNESS's trace-gated binaries, which the four steps above do not | |
| # reach: they lint `rustynes-core` with each feature, and | |
| # `cpu_boot_trace_diff` / `irq_trace_diff` live behind the same feature | |
| # names on `rustynes-test-harness`. Adding the core steps surfaced six | |
| # findings; adding this one surfaced another, in a binary the MiSTer rung | |
| # gates run on every comparison. | |
| - name: clippy (harness — trace-gated binaries) | |
| run: > | |
| cargo clippy -p rustynes-test-harness --all-targets | |
| --features cpu-boot-trace,irq-timing-trace,ppu-state-trace -- -D warnings | |
| # `phi2-write-sweep` (the v2.6.18 write-commit study) gates code in BOTH | |
| # `rustynes-ppu` and the harness's two sweep tests, and no step above | |
| # reaches any of it -- `--workspace --all-targets` is default features | |
| # only. Added with the feature's consumers rather than after a red `main`, | |
| # since "a cfg-gated module is presumed unlinted until a step names it" is | |
| # a lesson this repository has already paid for twice. The second | |
| # invocation pairs it with `ppu-state-trace`, which cfgs the fast-dot | |
| # dispatch out and so orphans the sweep's guard helper: the combination is | |
| # where that shows up, and neither feature alone reports it. | |
| - name: clippy (phi2-write-sweep) | |
| run: > | |
| cargo clippy -p rustynes-test-harness --all-targets | |
| --features test-roms,phi2-write-sweep -- -D warnings | |
| - name: clippy (phi2-write-sweep + ppu-state-trace) | |
| run: > | |
| cargo clippy -p rustynes-ppu --all-targets | |
| --features ppu-state-trace,phi2-write-sweep -- -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 | |
| # v2.7.4: rustynes-ios's iOS-only modules (the C-ABI shim and the CoreAudio | |
| # sink) are `cfg(target_os = "ios")`, so no PR job compiled them -- only the | |
| # tag-triggered ios.yml did. This type-checks the real files on Linux (cpal | |
| # has the same API on every backend) with only the Metal renderer stubbed, | |
| # and runs their tests. See the script's header for what it cannot check. | |
| - name: iOS shim + audio sink host type-check | |
| run: scripts/ios-host-typecheck.sh | |
| # 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 | |
| # v2.3.7 — the `commercial-roms` harness binaries had NEVER been linted. | |
| # `--all-targets` only builds targets whose `required-features` are | |
| # enabled, and nothing in CI enables `commercial-roms` (the corpus is | |
| # non-redistributable, so the TESTS behind it cannot run here). The | |
| # binaries themselves compile perfectly well without any ROM, though, and | |
| # the omission had let a 164-line `main`, two `next_back()` lints and a | |
| # `print_literal` accumulate unseen — found only when a reviewer read the | |
| # code a human never lints. | |
| # | |
| # Compile-only, so it needs no corpus and costs one incremental pass on a | |
| # runner that has already built this crate's dependencies. `debug-hooks` | |
| # rides along because two of those binaries additionally require it. | |
| - name: clippy (harness — commercial-roms binaries, compile-only) | |
| run: >- | |
| cargo clippy -p rustynes-test-harness --all-targets | |
| --features commercial-roms,debug-hooks -- -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' && needs.setup.outputs.heavy == 'true' }} | |
| runs-on: ${{ matrix.os }} | |
| # Typical: ~8 min ubuntu / ~16 min windows. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 45 | |
| 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 | |
| # v2.3.7 — the default-feature run cannot see a `mapper-audio`-off defect. | |
| # `rustynes-mappers` compiles two shapes: with the on-cart synthesizers | |
| # (default) and without, and only the second is what the `no_std` | |
| # cross-build and any downstream dependency-light consumer gets. CI linted | |
| # that shape and never RAN it, so a VRC7 save-state version check that | |
| # rejected every v2 blob on a no-audio build passed every gate — the | |
| # default build takes the other branch and is correct. Two review bots | |
| # caught it; no test could have, because no test ran in that | |
| # configuration. | |
| # | |
| # Linux only, and only this crate: the shape is target-independent, and | |
| # `rustynes-mappers` is the only crate whose behaviour is feature-gated | |
| # this way. Cheap (one extra compile of a mid-sized crate), so it runs on | |
| # every PR rather than only on a full run. | |
| - if: runner.os == 'Linux' | |
| run: cargo test -p rustynes-mappers --no-default-features | |
| test-roms: | |
| name: test (test-roms feature) | |
| # The SLOWEST job (release-mode compile + the heavy CPU / AccuracyCoin ROM | |
| # batteries, ~20 min). Runs on a FULL run — 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 — OR on any PR that | |
| # touches a path able to move an accuracy or visual vector (the `accuracy` | |
| # filter in the `changes` job, which carries the rationale). | |
| # | |
| # v2.3.9 A5. This was FULL-run only, which meant a regular feature PR never | |
| # ran the battery, so an accuracy regression could not be caught on the PR | |
| # that caused it — it landed, turned `main` red, and needed a second PR. | |
| # Sharpening the point: `main`'s ruleset requires exactly ONE status context, | |
| # `CI success`, and a SKIPPED job does not fail the aggregate. So the single | |
| # gate on merging was reporting a pass for a property it had not tested. | |
| # | |
| # The per-PR saving the full-run flag exists for is preserved rather than | |
| # traded away: measured over the last 40 merged PRs, 11 touch accuracy paths | |
| # and 29 do not, so roughly 72% of PRs still skip this entirely. | |
| # | |
| # NOT gated on the lint job, deliberately (changed 2026-09-23). This is | |
| # the critical path of every PR it runs on: lint (~5.6 min) followed by | |
| # this job (~26 min) made ~32 minutes of wall clock, and running them side | |
| # by side removes the lint time from it. The cost is a wasted release | |
| # compile on a PR whose lint fails, which on a PR that has already been | |
| # through the draft phase (see `setup`) is rare. | |
| needs: [changes, setup] | |
| if: >- | |
| ${{ needs.changes.outputs.code == 'true' | |
| && needs.setup.outputs.heavy == 'true' | |
| && (needs.setup.outputs.full == 'true' | |
| || needs.changes.outputs.accuracy == 'true') }} | |
| runs-on: ubuntu-26.04 | |
| # Typical: ~29 min. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 60 | |
| 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. | |
| # | |
| # Scoped to the two packages that HAVE a `test-roms` feature (2026-09-23). | |
| # `--workspace` compiled every crate in release -- the wgpu / winit / | |
| # egui / cpal frontend, libretro, the mobile bridge, netplay -- and re-ran | |
| # all of their tests in release mode, although none of that code is | |
| # behind the feature and the OS-matrix `test` job already runs it. The | |
| # battery itself lives in `rustynes-test-harness` (58 gated sites) and | |
| # one mappers corpus test; `rustynes-test-harness` does not depend on the | |
| # frontend. Locally the same scoping is | |
| # `cargo test --release -p rustynes-test-harness -p rustynes-mappers --features rustynes-test-harness/test-roms,rustynes-mappers/test-roms`. | |
| - run: >- | |
| cargo test --release | |
| -p rustynes-test-harness -p rustynes-mappers | |
| --features rustynes-test-harness/test-roms,rustynes-mappers/test-roms | |
| no_std: | |
| name: no_std build (thumbv7em-none-eabihf) | |
| needs: [changes, setup] | |
| if: >- | |
| ${{ needs.changes.outputs.code == 'true' | |
| && (needs.setup.outputs.full == 'true' || needs.changes.outputs.nostd == 'true') }} | |
| runs-on: ubuntu-26.04 | |
| # Typical: ~35 s. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 20 | |
| 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-26.04 | |
| # Typical: ~1.5 min. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 25 | |
| 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, setup] | |
| if: >- | |
| ${{ needs.changes.outputs.code == 'true' | |
| && (needs.setup.outputs.full == 'true' || needs.changes.outputs.libretro == 'true') }} | |
| runs-on: ubuntu-26.04 | |
| # Typical: ~1 min per target. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 25 | |
| 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-26.04 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). | |
| # `libc6-dev-arm64-cross` 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`. | |
| # | |
| # This asked for `gcc-aarch64-linux-gnu` until v2.3.9, and the comment that | |
| # stood here said why that was wrong without drawing the conclusion: it | |
| # named `libc6-dev-arm64-cross` as the package that actually lands the | |
| # headers, and stated that the cross linker is unused because this gate is | |
| # `cargo check` only. So the step installed a whole cross toolchain to | |
| # obtain a dependency it had already identified. The failure log makes the | |
| # cost concrete — 20+ packages, of which `Get:20` alone is | |
| # `gcc-13-aarch64-linux-gnu` at 21.1 MB, and it was mid-download when the | |
| # install timeout fired. bindgen runs the HOST clang against the sysroot and | |
| # never invokes the cross compiler. | |
| - name: Provision the aarch64 glibc headers for bindgen | |
| if: matrix.target == 'aarch64-unknown-linux-gnu' | |
| env: | |
| APT_PACKAGE: libc6-dev-arm64-cross | |
| run: .github/scripts/apt-install-retry.sh | |
| - name: Export the aarch64 bindgen sysroot | |
| if: matrix.target == 'aarch64-unknown-linux-gnu' | |
| run: | | |
| 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 and narrowed for the same reason. `libc6-dev-armhf-cross` lands the | |
| # 32-bit ARM glibc headers under `/usr/arm-linux-gnueabihf/include`; the | |
| # cross compiler and linker are unused here, since this gate is | |
| # `cargo check` only and the buildbot remains the authority on linking. | |
| - name: Provision the armhf glibc headers for bindgen | |
| if: matrix.target == 'armv7-unknown-linux-gnueabihf' | |
| env: | |
| APT_PACKAGE: libc6-dev-armhf-cross | |
| run: .github/scripts/apt-install-retry.sh | |
| - name: Export the armhf bindgen sysroot | |
| if: matrix.target == 'armv7-unknown-linux-gnueabihf' | |
| run: | | |
| 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-26.04 | |
| # Typical: ~7 min. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 40 | |
| 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-26.04 | |
| # Typical: instant. Bounded so a hung job cannot hold the | |
| # concurrency group -- see the block comment above `concurrency`. | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Note a draft run | |
| if: ${{ needs.setup.outputs.heavy == 'false' }} | |
| run: | | |
| echo "::notice::Draft PR: test and test-roms are deferred until the PR is marked ready for review (gh pr ready)." | |
| - 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." |