perf(runtime): let the GC header pick the side-registry probe on dynamic dispatch (#7850) #10529
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: Tests | |
| on: | |
| # Run on version tags so `release-packages.yml` can gate publish on a green | |
| # Tests workflow for the exact commit being released. Direct pushes to main | |
| # do NOT trigger tests — the gates that matter are PRs (pre-merge) and tags | |
| # (pre-release). | |
| push: | |
| tags: ['v*'] | |
| pull_request: | |
| branches: [main] | |
| # `labeled` is here so the optional opt-in jobs (parity, compile-smoke, | |
| # doc-tests) re-fire when a maintainer or the PR author applies the | |
| # `run-extended-tests` label. Without it, applying the label on an | |
| # existing PR wouldn't re-trigger the workflow. | |
| types: [opened, synchronize, reopened, labeled] | |
| paths-ignore: | |
| - 'docs/src/**' | |
| - '*.md' | |
| - '!CLAUDE.md' | |
| - '!CHANGELOG.md' | |
| schedule: | |
| # Nightly full-workspace cargo-test safety net (04:00 UTC). The per-PR | |
| # cargo-test gate only exercises crates affected by the diff | |
| # (scripts/ci_test_scope.py); a cross-crate regression that slips a scoped | |
| # PR is caught here within a day. `schedule` is not `pull_request`, so the | |
| # cargo-test job runs the full workspace. | |
| - cron: '0 4 * * *' | |
| # Manual escape hatch for the opt-in jobs. Maintainers (write access) | |
| # can dispatch the workflow against any ref with `run_extended_tests=true` | |
| # to run parity / compile-smoke / package smokes / doc-tests on demand | |
| # without tagging a release. | |
| workflow_dispatch: | |
| inputs: | |
| run_extended_tests: | |
| description: 'Run extended tests (parity, compile-smoke, package smokes, doc-tests)' | |
| type: boolean | |
| default: false | |
| # Superseded PR pushes cancel their in-flight run (the real minute saver); the | |
| # nightly cron and release-tag runs never do (#5960). Keying the group on the | |
| # event as well as the ref means a manual `workflow_dispatch` on `main` — the | |
| # only other run that shares `refs/heads/main` with the cron, since pushes to | |
| # main don't trigger this workflow — can't cancel a nightly mid-flight, and | |
| # `cancel-in-progress` is off for those events regardless. The nightly is the | |
| # only backstop for integration-suite regressions a scoped PR run can't see, so | |
| # it must always reach a conclusion. | |
| concurrency: | |
| group: test-${{ github.event_name }}-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| env: | |
| CARGO_TERM_COLOR: always | |
| MACOSX_DEPLOYMENT_TARGET: "13.0" | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| # Changeset gate: PRs touching crates/ must ship changelog.d/<PR>-<slug>.md | |
| # (see changelog.d/README.md; fragments fold into GitHub Release notes at | |
| # tag time via scripts/cut_release_notes.sh — CHANGELOG.md is frozen). | |
| # Standalone job, NOT a step inside `lint`, so it can be its own required | |
| # status check: lint carries unrelated red debt that gets admin-bypassed, | |
| # and this gate must not ride along with that. The `skip-changelog` label | |
| # skips it (the `labeled` trigger above refires the workflow, and a skipped | |
| # required check counts as satisfied). | |
| # --------------------------------------------------------------------------- | |
| changeset-gate: | |
| if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'skip-changelog') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Require a changelog.d/ fragment for crates/ changes | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate > files.json | |
| jq -r '.[].filename' files.json | grep -q '^crates/' || { echo "No crates/ changes — gate not applicable."; exit 0; } | |
| # The fragment must be ADDED in this PR (editing a leftover file | |
| # doesn't count) and match the root-level <digits>-<slug>.md shape | |
| # cut_release_notes.sh folds at release time. | |
| jq -r '.[] | select(.status == "added") | .filename' files.json | grep -qE '^changelog\.d/[0-9]+-[^/]+\.md$' && exit 0 | |
| echo "::error::This PR changes crates/ but adds no changelog.d/ fragment. Add changelog.d/<PR>-<slug>.md (see changelog.d/README.md) or apply the 'skip-changelog' label." | |
| exit 1 | |
| # --------------------------------------------------------------------------- | |
| # Lint: cargo fmt --check (formatting gate for every PR) | |
| # --------------------------------------------------------------------------- | |
| lint: | |
| # Was macos-14 — moved to ubuntu-latest in v0.5.428 since `cargo fmt | |
| # --check` is portable. The 6 multiplier-min cut is small in absolute | |
| # terms (lint runs in ~30s) but it's free. | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Setup Node.js for benchmark harness tests | |
| uses: actions/setup-node@v7 | |
| with: | |
| # Kept in lockstep with benchmark.yml's peer-comparison Node via the | |
| # shared .node-version pin — these harness tests exercise the same | |
| # comparison scripts that job runs. | |
| node-version-file: .node-version | |
| - name: Validate benchmark artifact and fallback gates | |
| run: | | |
| python3 -m unittest discover -s tests -p 'test_benchmark_gate.py' -v | |
| ./tests/test_benchmark_peer_fallback.sh | |
| ./tests/test_benchmark_output_verifier.sh | |
| bash -n benchmarks/compare.sh benchmarks/honest_bench/run.sh \ | |
| benchmarks/honest_bench/harness/run_http_bench.sh \ | |
| tests/test_benchmark_peer_fallback.sh | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: rustfmt, clippy | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| # PRs read from cache; only main writes new entries. | |
| # Avoids cache thrash from short-lived branches. | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Check formatting | |
| if: ${{ !cancelled() }} | |
| run: cargo fmt --all -- --check | |
| - name: Audit workspace architecture | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/workspace_architecture.py --self-test | |
| python3 scripts/workspace_architecture.py --check --print-summary | |
| - name: Public benchmark evidence freshness | |
| if: ${{ !cancelled() }} | |
| run: | | |
| PYTHONPATH=. python3 tests/test_public_baseline.py | |
| # README embedded table is optional since #6736 (marketing landing | |
| # page); this wrapper still enforces artifact freshness + RESULTS.md | |
| # drift. Kept out of public_baseline.py to preserve harness_fingerprint. | |
| python3 benchmarks/ci_public_baseline_check.py | |
| # File-size gate (v0.5.1019): fails the PR if any tracked source | |
| # file exceeds the LOC threshold (5000 initially; eventual target | |
| # is 2000). Big single-file modules are hard to read, slow IDE + | |
| # cargo-check incrementality, and hide regressions in code review. | |
| # Allowlist + exclusions live in the script. | |
| - name: File size limit | |
| if: ${{ !cancelled() }} | |
| run: ./scripts/check_file_size.sh | |
| # Well-known binding provenance pins: every third-party binding in | |
| # well_known_bindings.toml must carry an [bindings.<name>.upstream] | |
| # pin, and the lock-step rule (ported-at == version) must hold, so a | |
| # pin bump can't outrun the wrapper review it demands. Offline/CI-safe | |
| # (no network); the weekly update runs `--check --refresh` to surface | |
| # newly-soaked upstream releases as advisories. | |
| - name: Binding upstream pins (lock-step) | |
| if: ${{ !cancelled() }} | |
| run: node scripts/binding_pins.mjs --check | |
| # GC write-barrier store-site inventory: every raw heap-slot store in | |
| # perry-codegen / perry-runtime / perry-stdlib must be barriered or | |
| # carry a justified GC_STORE_AUDIT(...) marker (or a justified entry | |
| # in scripts/gc_store_site_allowlist.txt). Catches new unbarriered | |
| # old->young store paths before they become nondeterministic segfaults. | |
| - name: GC store-site inventory | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/gc_store_site_inventory.py --self-test | |
| python3 scripts/gc_store_site_inventory.py | |
| # Handle-vs-pointer address classification audit: POINTER_TAG payloads | |
| # can be heap pointers OR small registry handles (fetch/zlib/proxy/...), | |
| # and code must classify by magnitude through value/addr_class.rs before | |
| # dereferencing. Catches new hand-typed band literals (0x100000 etc.) | |
| # and new `as *const GcHeader` casts outside addr_class.rs / gc/ before | |
| # they become Linux-only segfaults (#1843, #4004, #4665, #4800 class). | |
| # Allowlist: scripts/addr_class_allowlist.txt. | |
| - name: Address-classification audit | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/addr_class_inventory.py --self-test | |
| python3 scripts/addr_class_inventory.py | |
| # Two reserved class ids sharing a value is silent and destructive: every | |
| # dispatch tower matches them in a fixed order, so the later arm becomes | |
| # unreachable and its whole method surface dies (#7576 killed the entire | |
| # TC39 iterator-helpers proposal that way), and any site discriminating on | |
| # class_id ALONE cross-matches — which can look correct for as long as the | |
| # two types agree on field layout and break the day either changes (#7587, | |
| # where `String(JSON.rawJSON(x))` was silently taking the JSX path). | |
| # | |
| # This SCANS rather than enumerating. #7576 shipped a Rust test listing | |
| # seven iterator ids, which is good and stays — but it could not catch | |
| # #7587, a different family that was not in the list. A gate whose | |
| # coverage depends on the same attention the bug depends on is not a gate. | |
| - name: Class-id collision audit | |
| if: ${{ !cancelled() }} | |
| run: python3 scripts/class_id_collisions.py | |
| # #7645. The copying minor skips its eligibility preflight — the walk that | |
| # proves nothing reachable is pinned — whenever the young-pin latch is | |
| # clear. That is sound only while EVERY creation of GC_FLAG_PINNED goes | |
| # through gc::pin_object, which is what arms the latch; a pin created any | |
| # other way lets the collector relocate a pinned object whose holder keeps | |
| # a raw address no scanner rewrites. This SCANS for both shapes the tree | |
| # has used — `gc_flags |= GC_FLAG_PINNED` and the raw `*gc_flags_ptr |= | |
| # 0x04` that hid two of the six pin sites from every grep — and fails on a | |
| # stale allowlist entry as well as on a new site. | |
| - name: GC pin-site custody audit | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/gc_pin_sites.py --self-test | |
| python3 scripts/gc_pin_sites.py | |
| # #7231. A runtime-side table holding a GC pointer IS a root, and nothing | |
| # static could see that class before: gc_root_dominance_check.py reads | |
| # emitted LLVM IR and a thread_local is not in it. #7226, #7239, #7268 and | |
| # #7274 were all found by hand, each one re-deriving the same enumeration. | |
| # This is that enumeration with a verdict required per holder — an | |
| # unclassified holder fails, and so does an inventory entry that no longer | |
| # matches (which is what makes a fix delete its own exemption). | |
| # | |
| # Cheap and build-free, so it belongs in `lint`, which IS a required | |
| # context — hazard 2 of CLAUDE.md's four is the step people forget, so | |
| # this gate is placed where that step does not exist. | |
| - name: Runtime GC-pointer holder custody audit | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/gc_runtime_root_holders.py --self-test | |
| python3 scripts/gc_runtime_root_holders.py | |
| # #7341 layer 3. A RuntimeHandleScope gives an object liveness; it does | |
| # nothing for a raw pointer already read out of the slot. Every rooting bug | |
| # in the quarantine sweep had rooting ALREADY -- what was missing was | |
| # ordering the re-read against the collection point. | |
| # `RuntimeHandle::across_{mut,const,nanbox}` expresses that ordering and | |
| # never binds the pre-call address. This counts the sites that still don't, | |
| # and only lets the number fall. Baseline: scripts/raw_handle_debt_baseline.txt. | |
| - name: Raw-handle debt ratchet | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/raw_handle_debt.py --self-test | |
| python3 scripts/raw_handle_debt.py | |
| # #7659: the step above compares the count against a baseline the SAME | |
| # DIFF is free to move -- add bare reads, raise the recorded number to | |
| # match, and it passes. `--update` refuses to raise, but nothing made CI | |
| # run `--update`. This compares the recorded files against the pull | |
| # request's merge base, so the number can only fall across a PR boundary. | |
| # | |
| # Gated on the event rather than on an empty variable: a `push` build has | |
| # no merge base, but a `pull_request` build with an unresolvable one is a | |
| # comparison that did not happen, and the script fails on that rather | |
| # than passing (see `git_show`). | |
| - name: Raw-handle debt ratchet vs. merge base | |
| if: ${{ !cancelled() && github.event_name == 'pull_request' }} | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null \ | |
| || git fetch --no-tags --depth=1 origin "$BASE_SHA" | |
| python3 scripts/raw_handle_debt.py --no-raise-vs "$BASE_SHA" | |
| # The gap-suite ratchet decides whether conformance-smoke goes red, so | |
| # its own logic is unit-checked on the cheap job rather than only being | |
| # exercised 8 shards deep. | |
| - name: Gap snapshot checker self-test | |
| if: ${{ !cancelled() }} | |
| run: python3 scripts/gap_snapshot.py --self-test | |
| # Two halves of the same gate (#7582). | |
| # | |
| # `--self-test` unit-checks the checker. `--audit` runs the checker's | |
| # OFFLINE half against the committed files: every known_failures.json | |
| # entry must carry provenance (issue + date, #797), name a test that | |
| # still exists, and — for gap-suite entries — be corroborated by | |
| # `test-parity/gap_snapshot.json`, which is GENERATED and bidirectional. | |
| # An entry absent from that snapshot is one the snapshot asserts passes, | |
| # i.e. a suppression that has outlived its bug. | |
| # | |
| # This belongs on `lint` rather than only on `parity` because `parity` is | |
| # TAG-gated: the live half of the ratchet fires after every merge it was | |
| # meant to judge. `test_gap_diagchannel_3082_3084_3085_3086` sat here from | |
| # 2026-07-04 and absorbed a real data-loss regression for six days (#7580); | |
| # this step would have named it on the day it was added. Costs ~0.1s and | |
| # runs no tests. | |
| - name: Platform-aware parity allowlist self-test | |
| if: ${{ !cancelled() }} | |
| run: python3 scripts/parity_known_failures.py --self-test | |
| - name: Parity allowlist ratchet (provenance + stale entries) | |
| if: ${{ !cancelled() }} | |
| run: python3 scripts/parity_known_failures.py --audit | |
| # Moving-GC gate wiring. The GC gates are the ones this repo has most | |
| # often found unable to fail (CLAUDE.md's four hazards), and every miss so | |
| # far was caught by a human re-deriving it mid-incident. This asserts the | |
| # mechanical half from `lint`, which IS a required context: each gate job | |
| # must actually execute on main-line code (a push to `main`, or the | |
| # nightly `schedule` — a tag-only run adjudicates nothing, it fires after | |
| # every merge it was supposed to judge), must not carry job-level | |
| # `continue-on-error`, must not swallow its gating step's exit status, and | |
| # must not let a new merge cancel the previous main run. | |
| # | |
| # It cannot check branch protection's required-context list — that is | |
| # server-side state, not a file in the tree — and says so; `--list` prints | |
| # what is and is not covered. | |
| - name: Moving-GC gate wiring | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/gc_gate_wiring_check.py --self-test | |
| python3 scripts/gc_gate_wiring_check.py | |
| # The other half of the same hazard (#7255). `gc_gate_wiring_check.py` | |
| # asserts the matrix JOB can run; this asserts the matrix ARMS can fail. | |
| # Four of the six PR-gating arms sat at copy-minor 0/50 for five weeks | |
| # while the script's header advertised 12/22, because an all-UNVER table | |
| # exits 0. Both checks are build-free, so they belong in `lint`: | |
| # `--self-test` covers the red/green rule itself, `--check-registry` | |
| # covers the known-inert list (names resolve to real arms, every entry | |
| # cites an issue, and the matrix still calls the checker at all — a gate | |
| # nobody invokes is the same hazard one level up). | |
| - name: GC matrix liveness gate | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/gc_matrix_liveness_check.py --self-test | |
| python3 scripts/gc_matrix_liveness_check.py --check-registry | |
| # Dark-test gate. Four of this repo's suites are driven by an explicit | |
| # registry rather than a glob, and a test file added without its registry | |
| # line runs NOWHERE while its PR stays green — #7192, #7216, #7252 and | |
| # #7270/#7271 all shipped that way against test-parity/gc_repsel_corpus.txt. | |
| # | |
| # Registration checks already existed for two of those prefixes, but both | |
| # live behind a 90-minute compiler build, behind a changed-paths relevance | |
| # filter, and in workflows that are NOT in branch protection — so the check | |
| # could not run on the pull request that needed it. This is the pure | |
| # filesystem-and-text half (~0.2s, no compiler, no Node), placed in `lint` | |
| # BECAUSE `lint` is already a required context: hazard 2 is the step people | |
| # forget, so this gate is put where that step does not exist. | |
| # | |
| # The self-test plants an unregistered file into each mechanism and asserts | |
| # the gate names it, then removes it and asserts green — over the real | |
| # registries, through an in-memory overlay, so the checkout is never | |
| # mutated. Each mechanism also floors its candidate set, so a stale glob | |
| # fails loudly instead of making every future run vacuously green. | |
| # | |
| # `!cancelled()` is hazard 4 in a costume nobody has named yet: `lint` is a | |
| # SEQUENCE of independent gates, and a step that fails takes every later | |
| # step in the job to `skipped`. That is not hypothetical here — `Public | |
| # benchmark evidence freshness` has failed on `main` on every run from | |
| # 2026-07-29 onward, so `File size limit`, `GC store-site inventory`, | |
| # `Address-classification audit`, `Gap snapshot checker self-test` and | |
| # `Platform-aware parity allowlist self-test` have all been skipped for | |
| # days while the job dutifully reported red for an unrelated reason. A gate | |
| # that never executes cannot fail on its own subject. This step costs 0.2s | |
| # and shares no state with anything above it, so it always speaks. | |
| # (`!cancelled()` rather than `always()`: a cancelled run should stay | |
| # cancelled.) The five steps above deserve the same treatment; that is a | |
| # separate change from this one. | |
| - name: Test registration (dark tests) | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/check_test_registration.py --self-test | |
| python3 scripts/check_test_registration.py | |
| # #7672: the GC test guards CLEAR ~20 process-global side tables from | |
| # whatever libtest thread constructs a guard, and nothing requires a | |
| # READER to take the clearing lock. Three flakes in two days came from | |
| # that (#7665 x2, #7671), each exposed by an unrelated PR that changed the | |
| # parallel schedule, so the author of the exposing PR paid the diagnosis. | |
| # | |
| # The class is fixed by storage, not by a lock: `per_test_global!` | |
| # gives each thread its own table in a test build. This gate derives the | |
| # clear list from the guards' own source and fails on any bare `static` | |
| # left behind, so a NEW sink cannot be added quietly and a new READER | |
| # never has to remember anything. Its allowlist entries each cite an | |
| # issue, and an entry that matches nothing fails too. | |
| # | |
| # Pure text, ~1s, no compiler — in `lint` because `lint` is a required | |
| # context (hazard 2), and `!cancelled()` for the reason given above. | |
| - name: Per-test global sinks | |
| if: ${{ !cancelled() }} | |
| run: | | |
| python3 scripts/global_sink_isolation.py --self-test | |
| python3 scripts/global_sink_isolation.py | |
| # --------------------------------------------------------------------------- | |
| # Clippy — enforces the deny-level lints in [workspace.lints] (root | |
| # Cargo.toml). `cargo clippy` exits nonzero only on `deny` lints, so | |
| # warn-level output is informational and never blocks a PR. The product leg | |
| # gives fast feedback for the CLI; the host-compatible leg names every Linux | |
| # package explicitly. Neither scope depends on Cargo default-members. | |
| # --------------------------------------------------------------------------- | |
| clippy: | |
| name: Clippy (${{ matrix.scope }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| scope: [product, host-compatible] | |
| env: | |
| RUSTC_WRAPPER: sccache | |
| SCCACHE_GHA_ENABLED: "false" | |
| SCCACHE_DIR: ${{ github.workspace }}/.sccache | |
| SCCACHE_CACHE_SIZE: "12G" | |
| CARGO_INCREMENTAL: "0" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: clippy | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Install sccache | |
| uses: mozilla-actions/sccache-action@v0.0.10 | |
| # Same tarball'd-disk-cache pattern as api-docs-drift (see the comment | |
| # there for why SCCACHE_GHA_ENABLED is off). | |
| - name: Cache sccache objects | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ github.workspace }}/.sccache | |
| key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }} | |
| restore-keys: | | |
| sccache-${{ runner.os }}-perry- | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Run clippy for explicit scope | |
| run: | | |
| if [[ "${{ matrix.scope }}" == "product" ]]; then | |
| cargo clippy -p perry --bins | |
| else | |
| mapfile -t excluded < <(python3 scripts/workspace_architecture.py \ | |
| --print-excluded-scope host-compatible) | |
| cargo_args=(--workspace) | |
| for package in "${excluded[@]}"; do | |
| cargo_args+=(--exclude "$package") | |
| done | |
| cargo clippy "${cargo_args[@]}" | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # rustc warnings gate | |
| # | |
| # `cargo check` with `-D warnings`, so a PR cannot add a rustc warning. This | |
| # is deliberately separate from the clippy job above: clippy's own warn-level | |
| # lints are informational here, while rustc's are not. | |
| # | |
| # Both legs are needed because they compile different code. `perry` depends on | |
| # perry-runtime with `default-features = false`, so the product leg sees a | |
| # runtime with regex-engine, diagnostics and temporal off, where items the | |
| # workspace leg finds live are dead. The workspace leg passes `--all-targets` | |
| # so test and bench targets count too — without it, test-only code drifts. | |
| # | |
| # perry-ui-macos is in the excluded scope (this runs on ubuntu), so its | |
| # warnings are not gated here. | |
| # --------------------------------------------------------------------------- | |
| rustc-warnings: | |
| name: Warnings (${{ matrix.scope }}) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 60 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| scope: [product, host-compatible] | |
| env: | |
| RUSTFLAGS: -D warnings | |
| RUSTC_WRAPPER: sccache | |
| SCCACHE_GHA_ENABLED: "false" | |
| SCCACHE_DIR: ${{ github.workspace }}/.sccache | |
| SCCACHE_CACHE_SIZE: "12G" | |
| CARGO_INCREMENTAL: "0" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Install sccache | |
| uses: mozilla-actions/sccache-action@v0.0.10 | |
| - name: Cache sccache objects | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ github.workspace }}/.sccache | |
| key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }} | |
| restore-keys: | | |
| sccache-${{ runner.os }}-perry- | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Check for rustc warnings | |
| run: | | |
| if [[ "${{ matrix.scope }}" == "product" ]]; then | |
| cargo check -p perry --bins | |
| else | |
| mapfile -t excluded < <(python3 scripts/workspace_architecture.py \ | |
| --print-excluded-scope host-compatible) | |
| cargo_args=(--workspace --all-targets) | |
| for package in "${excluded[@]}"; do | |
| cargo_args+=(--exclude "$package") | |
| done | |
| cargo check "${cargo_args[@]}" | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # API docs drift gate (#465) | |
| # | |
| # Regenerates `docs/src/api/reference.md` and `docs/api/perry.d.ts` from | |
| # the compile-time manifest in `crates/perry-api-manifest/src/entries.rs`, | |
| # then `git diff --exit-code`s the result. Fails when a code change updated | |
| # the manifest without re-committing the artifacts. Closes the "Release | |
| # workflow regenerates docs automatically (no drift from code)" criterion | |
| # — committing the diff is the easiest way to keep the docs in sync, | |
| # since the diff is reviewable in the PR that introduces it. | |
| # --------------------------------------------------------------------------- | |
| api-docs-drift: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| env: | |
| RUSTC_WRAPPER: sccache | |
| # sccache on a LOCAL DISK cache, persisted as a single tarball via | |
| # actions/cache (see the "Cache sccache objects" step) — NOT the GitHub | |
| # Actions cache backend (SCCACHE_GHA_ENABLED). The GHA backend stores one | |
| # cache object per compilation unit; GitHub's cache service throttled / | |
| # LRU-evicted the thousands of tiny entries, so a full build wrote ~3.3k | |
| # objects (≈35 min of write time) yet the next run got ~0% Rust hits | |
| # (measured: 3 hits / 3209 misses, 613 write errors) — i.e. every run | |
| # recompiled the dependency graph cold. A single tarball'd disk cache | |
| # restores in one step and gives real cross-run hit rates. Note | |
| # SCCACHE_CACHE_SIZE is honoured by the disk backend (it was a silent | |
| # no-op under the GHA backend, which is why the old "2G" never mattered). | |
| SCCACHE_GHA_ENABLED: "false" | |
| SCCACHE_DIR: ${{ github.workspace }}/.sccache | |
| SCCACHE_CACHE_SIZE: "12G" | |
| CARGO_INCREMENTAL: "0" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Install sccache | |
| uses: mozilla-actions/sccache-action@v0.0.10 | |
| # Persist the sccache disk cache across runs. The github.job + | |
| # github.run_id key makes every run (including PRs) save its own fresh | |
| # entry — distinct per job so the three sccache jobs don't collide on | |
| # save — while the shared prefix restore-keys pull the most recent prior | |
| # cache from ANY of them. The object cache therefore warms continuously | |
| # and cross-pollinates instead of starting cold each run. | |
| - name: Cache sccache objects | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ github.workspace }}/.sccache | |
| key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }} | |
| restore-keys: | | |
| sccache-${{ runner.os }}-perry- | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Regenerate API docs | |
| run: ./scripts/regen_api_docs.sh | |
| - name: Check for drift | |
| run: | | |
| if ! git diff --quiet -- docs/src/api/reference.md docs/api/perry.d.ts; then | |
| echo "" | |
| echo "::error::API docs drift detected. The compile-time manifest in" | |
| echo "::error::crates/perry-api-manifest/src/entries.rs changed but the" | |
| echo "::error::generated artifacts under docs/ weren't regenerated." | |
| echo "" | |
| echo "Fix by running:" | |
| echo " ./scripts/regen_api_docs.sh" | |
| echo " git add docs/src/api/reference.md docs/api/perry.d.ts" | |
| echo " git commit -m 'docs: regenerate API reference + .d.ts'" | |
| echo "" | |
| echo "Diff:" | |
| git --no-pager diff --stat -- docs/src/api/reference.md docs/api/perry.d.ts | |
| echo "" | |
| git --no-pager diff -- docs/src/api/reference.md docs/api/perry.d.ts | head -200 | |
| exit 1 | |
| fi | |
| echo "✅ API docs match the manifest." | |
| # --------------------------------------------------------------------------- | |
| # Rust unit tests (266+ tests across all crates) | |
| # --------------------------------------------------------------------------- | |
| # Note: a separate `build` job that produced runtime/stdlib/compiler | |
| # artifacts USED to live here. It only fed `binary-size` (which now does | |
| # its own quick build) — every other job did `cargo build --release` | |
| # itself anyway, so `needs: build` was a serializing barrier with no | |
| # cache benefit. Removed in v0.5.387 along with the `actions/cache@v4` | |
| # blocks (replaced by `Swatinem/rust-cache@v2`, which handles target/ | |
| # pruning intelligently and avoids the disk-pressure issue that | |
| # required the manual simulator-runtime wipe + cache=registry-only | |
| # workaround). Each downstream job below builds in parallel directly. | |
| cargo-test: | |
| # Was macos-14 — moved to ubuntu-latest in v0.5.392 to drop the 10× | |
| # billing weight. The centralized Linux test scope already filters out | |
| # platform-specific UI crates, so | |
| # the platform-independent test set runs identically on Linux. The | |
| # macOS-host coverage we lose here is negligible — these tests | |
| # don't exercise any platform behavior; they're pure logic + | |
| # codegen. | |
| runs-on: ubuntu-latest | |
| # The per-package serial build+prune loop below (plus the big stdlib links) | |
| # takes ~50-60 min with a WARM sccache disk cache. A fully cold cache | |
| # (recompiling the whole dependency graph) measured ~90-103 min, and PRs | |
| # that invalidate perry-runtime/perry-codegen previously exceeded the old | |
| # 120-min bound entirely. The bound is 180 to (a) leave headroom while the | |
| # disk cache warms after the sccache-backend fix, and (b) still cut a true | |
| # hang (e.g. a flaky link SIGBUS retry storm). Once warm hit rates are | |
| # confirmed in CI this can come back down. NOTE: the old "~45-50 min" figure | |
| # predated the sccache GHA-backend rot — it was never accurate once that | |
| # cache stopped delivering Rust hits. | |
| timeout-minutes: 180 | |
| # sccache (compiler-level cache, shared across ALL branches/jobs via the | |
| # GitHub Actions cache backend) on top of Swatinem/rust-cache (target/ + | |
| # registry). rust-cache only writes on main (save-if below), so PRs can't | |
| # warm it — and any change to the constantly-churning perry-runtime | |
| # invalidates the whole downstream target/, whereas sccache still reuses | |
| # the unchanged compilation units. CARGO_INCREMENTAL=0 is required: sccache | |
| # cannot cache incremental builds. SCCACHE_CACHE_SIZE bounds the on-runner | |
| # cache so it doesn't compound this job's known disk pressure (the prune | |
| # loop below). | |
| env: | |
| RUSTC_WRAPPER: sccache | |
| # sccache on a LOCAL DISK cache, persisted as a single tarball via | |
| # actions/cache (see the "Cache sccache objects" step) — NOT the GitHub | |
| # Actions cache backend (SCCACHE_GHA_ENABLED). The GHA backend stores one | |
| # cache object per compilation unit; GitHub's cache service throttled / | |
| # LRU-evicted the thousands of tiny entries, so a full build wrote ~3.3k | |
| # objects (≈35 min of write time) yet the next run got ~0% Rust hits | |
| # (measured: 3 hits / 3209 misses, 613 write errors) — i.e. every run | |
| # recompiled the dependency graph cold. A single tarball'd disk cache | |
| # restores in one step and gives real cross-run hit rates. Note | |
| # SCCACHE_CACHE_SIZE is honoured by the disk backend (it was a silent | |
| # no-op under the GHA backend, which is why the old "2G" never mattered). | |
| SCCACHE_GHA_ENABLED: "false" | |
| SCCACHE_DIR: ${{ github.workspace }}/.sccache | |
| SCCACHE_CACHE_SIZE: "12G" | |
| CARGO_INCREMENTAL: "0" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Install sccache | |
| uses: mozilla-actions/sccache-action@v0.0.10 | |
| # Persist the sccache disk cache across runs. The github.job + | |
| # github.run_id key makes every run (including PRs) save its own fresh | |
| # entry — distinct per job so the three sccache jobs don't collide on | |
| # save — while the shared prefix restore-keys pull the most recent prior | |
| # cache from ANY of them. The object cache therefore warms continuously | |
| # and cross-pollinates instead of starting cold each run. | |
| - name: Cache sccache objects | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ github.workspace }}/.sccache | |
| key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }} | |
| restore-keys: | | |
| sccache-${{ runner.os }}-perry- | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| # #5892 — the auto-opt archive cache (target/perry-auto-<hash>) keys on | |
| # perry-runtime's source hash and does NOT account for ext-crate sources, | |
| # so a rust-cache-restored stale dir keeps linking OUTDATED ext archives | |
| # into test-compiled binaries even after the ext sources were fixed (this | |
| # is how the #5911 shim fix failed to clear the issue_4903 hang: the | |
| # restored cache still carried pre-fix optimized archives). Evict it so | |
| # every run links archives built from the checked-out tree; costs one | |
| # ~6-9 min rebuild on the first perry compile of the run. | |
| - name: Evict stale auto-opt archives (#5892) | |
| run: | | |
| rm -rf target/perry-auto-* target/debug/libperry_ext_*.a 2>/dev/null || true | |
| - name: Run cargo test | |
| # The exclusions below are maintained once in | |
| # workspace-architecture.json and consumed by ci_test_scope.py: | |
| # - perry-ui-macos / perry-ui-ios / perry-ui-tvos / perry-ui-watchos | |
| # / perry-ui-visionos: depend on `objc2` which only compiles on | |
| # Apple platforms (`compile_error!` in objc2/src/lib.rs:219). | |
| # - perry-ui-gtk4: needs system pango/gtk via pkg-config; runner | |
| # image doesn't have libgtk-4-dev installed by default. | |
| # - perry-ui-android: needs Android NDK. | |
| # - perry-ui-windows: needs win32 headers. | |
| # - perry-ui-windows-winui: re-exports perry-ui-windows, so it inherits | |
| # the same win32 / webview2-com dependency that won't build on Linux. | |
| env: | |
| # Rust's Ubuntu target can drive `cc` with `-fuse-ld=lld`; on the | |
| # shared runner this has repeatedly terminated large test links with | |
| # SIGBUS. Use the system linker for the cargo-test gate. | |
| CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" | |
| # Keep test artifacts small enough for the shared runner disk. The | |
| # cargo-test job does not need line tables, and debug info was enough | |
| # to make later package archives hit ENOSPC after several package | |
| # test builds accumulated in target/debug. | |
| CARGO_PROFILE_TEST_DEBUG: "0" | |
| CARGO_PROFILE_DEV_DEBUG: "0" | |
| # For `gh pr view` (PR changed-file list → affected-crate scope). | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| ( | |
| while sleep 60; do | |
| echo "cargo-test still running at $(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| done | |
| ) & | |
| cargo_test_heartbeat_pid=$! | |
| trap 'kill "$cargo_test_heartbeat_pid" 2>/dev/null || true' EXIT | |
| # Test scope: a per-PR run only exercises the crates the diff can | |
| # affect (changed crates + their reverse-dependency closure, plus a | |
| # `perry` edge for runtime-linked stdlib/ext archives) instead of the | |
| # whole workspace (~90 min). Release tags, the nightly cron, and | |
| # workflow_dispatch run the FULL workspace as the safety net. See | |
| # scripts/ci_test_scope.py for the rules. | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| changed_files="$(gh pr view "${{ github.event.pull_request.number }}" \ | |
| --json files --jq '.files[].path')" | |
| echo "Changed files in PR:"; printf '%s\n' "$changed_files" | |
| scope="$(printf '%s\n' "$changed_files" | python3 scripts/ci_test_scope.py)" | |
| else | |
| scope="$(python3 scripts/ci_test_scope.py --full </dev/null)" | |
| fi | |
| echo "Packages in test scope:"; printf '%s\n' "$scope" | |
| if [ -z "$scope" ]; then | |
| echo "No crates affected by this diff — nothing to test." | |
| exit 0 | |
| fi | |
| # #1444: perry-runtime's tests share process-global state — the | |
| # per-thread arena/GC, the timer queues, and the `NOTIFIED` flag are | |
| # process singletons. Running them across the default test-harness | |
| # thread pool lets one test's `js_notify_main_thread` / timer | |
| # scheduling perturb another's wait budget (the event_pump timing | |
| # flakes) and races the GC/threading tests into intermittent SIGSEGV. | |
| # Run perry-runtime single-threaded so the tests can't interfere. | |
| if [ "${{ github.event_name }}" != "pull_request" ]; then | |
| # ---- FULL run: release tags / nightly cron / workflow_dispatch ---- | |
| # Every target, including the slow auto-optimize integration tests. | |
| if printf '%s\n' "$scope" | grep -qx 'perry-runtime'; then | |
| RUST_TEST_THREADS=1 cargo test -p perry-runtime | |
| fi | |
| # `cargo test` only builds lib/bin/test targets — NOT the `staticlib` | |
| # crate-type — so libperry_runtime.a / libperry_stdlib.a are never | |
| # produced by the steps above; they only exist if restored from the | |
| # cache. Integration tests that compile with PERRY_NO_AUTO_OPTIMIZE=1 | |
| # (e.g. functional_batch2_regressions) link the prebuilt archive | |
| # directly and fail with "Could not find libperry_runtime.a" if the | |
| # cached staticlib was invalidated. Build them explicitly. | |
| if printf '%s\n' "$scope" | grep -qE '^(perry|perry-stdlib)$'; then | |
| cargo build -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static | |
| fi | |
| find target/debug/deps -maxdepth 1 -type f -perm -111 ! -name '*.so' -delete | |
| # Large perry / perry-stdlib integration-test binaries: serialize | |
| # builds and prune linked executables between packages so the runner | |
| # disk doesn't exhaust mid-job. | |
| export CARGO_BUILD_JOBS=1 | |
| for package in $(printf '%s\n' "$scope" | grep -vx 'perry-runtime'); do | |
| echo "::group::cargo test -p $package" | |
| cargo test -p "$package" | |
| echo "::endgroup::" | |
| cargo clean -p "$package" || true | |
| find target/debug/deps -maxdepth 1 -type f -perm -111 ! -name '*.so' -delete | |
| done | |
| else | |
| # ---- FAST per-PR run (<10 min target) ---- | |
| # Unit / lib / bin tests for the affected crates only. The slow | |
| # auto-optimize *integration* tests (tests/*.rs — each shells out to | |
| # `perry compile`, ~4–6 min apiece, 163 of them in crates/perry | |
| # alone) are NOT run wholesale per-PR; they run in the nightly full | |
| # job, on release tags, and on demand via the `run-extended-tests` | |
| # label. No staticlib dependency to build here (no integration | |
| # tests). | |
| # | |
| # #5960: the suites the DIFF NAMES — i.e. a PR's own new/edited | |
| # acceptance suite — do run per-PR, in the separate `e2e-scoped` | |
| # job below. Without it a PR's acceptance test could not fail its | |
| # own CI (#5938). | |
| # | |
| # Run each crate in its OWN `cargo test` invocation — NOT one | |
| # multi-package invocation. Building several crates together unifies | |
| # perry-runtime's cargo features, which turns on optional impls (e.g. | |
| # `fetch`) whose extern symbols (`js_fetch_with_options`) live in a | |
| # separate crate the other test binaries don't link → `undefined | |
| # reference` at link. Per-package builds keep each crate's | |
| # perry-runtime feature set isolated. CARGO_BUILD_JOBS=1 also bounds | |
| # the heavy per-binary runtime link so the runner doesn't OOM. | |
| # `--with-tests` drops crates whose `src/` has no unit tests (their | |
| # lib test binary would link the runtime for zero tests). | |
| export CARGO_BUILD_JOBS=1 | |
| # perry-runtime first, single-threaded (process-global state); it is | |
| # a lib-only crate, so filter to --lib. | |
| if printf '%s\n' "$scope" | grep -qx 'perry-runtime'; then | |
| RUST_TEST_THREADS=1 cargo test --lib -p perry-runtime | |
| fi | |
| rest="$(printf '%s\n' "$scope" | grep -vx 'perry-runtime' \ | |
| | python3 scripts/ci_test_scope.py --with-tests || true)" | |
| echo "Crates with unit tests in scope:"; printf '%s\n' "$rest" | |
| for package in $rest; do | |
| # `cargo test --lib` errors on a bin-only crate (perry), so pick | |
| # the target filter per package: --lib --bins when it has a lib | |
| # (lenient if it has no bins), else --bins. | |
| if printf '%s\n' "$package" | python3 scripts/ci_test_scope.py --has-lib; then | |
| target_filter="--lib --bins" | |
| else | |
| target_filter="--bins" | |
| fi | |
| echo "::group::cargo test $target_filter -p $package" | |
| cargo test $target_filter -p "$package" | |
| echo "::endgroup::" | |
| done | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # Scoped e2e: run the integration suites NAMED BY THE DIFF (#5960) | |
| # | |
| # The per-PR `cargo-test` gate is `--lib --bins` only, so *no* `tests/*.rs` | |
| # integration suite runs on a PR — including the PR's own. That is how #5938 | |
| # landed with its acceptance suite (`capture_rereg_renamed_class.rs`) red | |
| # through green required checks: the suite was in the diff, was compiled into | |
| # the scope listing, and was never executed. Running every suite per-PR is not | |
| # an option (163 in `crates/perry` alone, each shelling out to `perry | |
| # compile`), so this tier runs exactly the ones the diff names: | |
| # | |
| # changed `crates/<pkg>/tests/<suite>.rs` -> cargo test -p <pkg> --test <suite> | |
| # | |
| # (plus suite module dirs and `common/` helper dirs — see | |
| # scripts/ci_e2e_scope.py — diff-named suites capped at 12; the perry-codegen | |
| # source map added in #7708 is uncapped and in-process). | |
| # | |
| # Cost: PRs that touch no integration suite — the large majority — finish in | |
| # ~20-30s of checkout + scope computation and never install a toolchain or | |
| # build anything (the scope step parses Cargo.toml directly, no `cargo | |
| # metadata`). Only a PR that adds or edits a suite pays the build, and it pays | |
| # it in PARALLEL with `cargo-test`, which is the longer pole anyway. | |
| # | |
| # NOT covered: a source change that regresses an *existing* suite the diff | |
| # doesn't name (#6037 class). There is no coverage data to map that with, and | |
| # a crate-level map (perry-codegen -> all of perry's suites) is precisely the | |
| # full run we're avoiding. The nightly full `cargo test` remains the backstop | |
| # for that class — hence the concurrency carve-out above. | |
| # --------------------------------------------------------------------------- | |
| e2e-scoped: | |
| # PR-only: tags / nightly / workflow_dispatch already run every suite in the | |
| # full `cargo-test` path. | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| # MUST cover the worst case the per-suite bound allows, or the job wall | |
| # clock kills legitimately-running suites and we get an uninformative | |
| # "cancelled" instead of the per-suite `::error::` — reintroducing, one | |
| # level up, exactly what `timeout 1500` below exists to prevent. | |
| # DEFAULT_CAP (ci_e2e_scope.py) = 12 diff-named suites | |
| # NAMED_SUITE_TIMEOUT_S = 1500s = 25 min -> 12 x 25 = 300 | |
| # #7708 mapped perry-codegen = 22 suites, uncapped, but in-process: | |
| # MAPPED_SUITE_TIMEOUT_S = 300s = 5 min -> 22 x 5 = 110 | |
| # + 2 known-failure checks at 5 min = 10 | |
| # + 30 min for toolchain/cache/staticlib build | |
| # total = 450 | |
| # Keep these in sync with ci_e2e_scope.py. This is a backstop, not a budget: | |
| # every mapped suite measured 2.2-10.4 s wall clock, each suite is | |
| # independently bounded, and the common case selects zero suites and exits | |
| # in ~20-30s. | |
| timeout-minutes: 450 | |
| env: | |
| RUSTC_WRAPPER: sccache | |
| SCCACHE_GHA_ENABLED: "false" | |
| SCCACHE_DIR: ${{ github.workspace }}/.sccache | |
| SCCACHE_CACHE_SIZE: "12G" | |
| CARGO_INCREMENTAL: "0" | |
| steps: | |
| # Nothing here pushes back to the repo, and the job compiles third-party | |
| # crates (build scripts run), so don't leave a git credential on disk. | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # Cheap gate: no toolchain, no cargo, no cache restore. Every step below | |
| # is skipped when the diff names no suite. | |
| - name: Compute e2e suite scope | |
| id: scope | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| python3 scripts/ci_e2e_scope.py --self-test | |
| changed_files="$(gh pr view "${{ github.event.pull_request.number }}" \ | |
| --json files --jq '.files[].path')" | |
| suites="$(printf '%s\n' "$changed_files" | python3 scripts/ci_e2e_scope.py)" | |
| # #7708: the held-out tests, so the run step can skip them AND assert | |
| # each one still fails. | |
| { | |
| echo 'exclusions<<PERRY_EOF' | |
| python3 scripts/ci_e2e_scope.py --exclusions | |
| echo 'PERRY_EOF' | |
| } >> "$GITHUB_OUTPUT" | |
| if [ -z "$suites" ]; then | |
| echo "No integration suite named by this diff — nothing to run." | |
| echo "suites=" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "Integration suites in scope:" | |
| printf '%s\n' "$suites" | |
| { | |
| echo 'suites<<PERRY_EOF' | |
| printf '%s\n' "$suites" | |
| echo 'PERRY_EOF' | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Install Rust toolchain | |
| if: steps.scope.outputs.suites != '' | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Install sccache | |
| if: steps.scope.outputs.suites != '' | |
| uses: mozilla-actions/sccache-action@v0.0.10 | |
| - name: Cache sccache objects | |
| if: steps.scope.outputs.suites != '' | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ github.workspace }}/.sccache | |
| key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }} | |
| restore-keys: | | |
| sccache-${{ runner.os }}-perry- | |
| - uses: Swatinem/rust-cache@v2 | |
| if: steps.scope.outputs.suites != '' | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| # Same reasoning as cargo-test: a rust-cache-restored auto-opt archive dir | |
| # keys only on perry-runtime's source hash and would link stale ext | |
| # archives into the binaries these suites compile (#5892). | |
| - name: Evict stale auto-opt archives (#5892) | |
| if: steps.scope.outputs.suites != '' | |
| run: rm -rf target/perry-auto-* target/debug/libperry_ext_*.a 2>/dev/null || true | |
| - name: Run scoped integration suites | |
| if: steps.scope.outputs.suites != '' | |
| env: | |
| # lld has repeatedly SIGBUS'd large test links on the shared runner. | |
| CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" | |
| CARGO_PROFILE_TEST_DEBUG: "0" | |
| CARGO_PROFILE_DEV_DEBUG: "0" | |
| # Bound the heavy per-binary runtime link so the runner doesn't OOM. | |
| CARGO_BUILD_JOBS: "1" | |
| SUITES: ${{ steps.scope.outputs.suites }} | |
| EXCLUSIONS: ${{ steps.scope.outputs.exclusions }} | |
| run: | | |
| # `cargo test` never builds the `staticlib` crate-type, so | |
| # libperry_{runtime,stdlib}.a don't exist unless built explicitly — | |
| # and the suites that compile with PERRY_NO_AUTO_OPTIMIZE=1 link them | |
| # directly ("Could not find libperry_runtime.a" otherwise). | |
| if printf '%s\n' "$SUITES" | grep -qE '^(perry|perry-stdlib) '; then | |
| cargo build -p perry-runtime -p perry-stdlib \ | |
| -p perry-runtime-static -p perry-stdlib-static | |
| fi | |
| status=0 | |
| # #7708: every suite runs with its known-failing tests skipped, in | |
| # whichever tier selected it. Holding out a TEST instead of a SUITE is | |
| # what lets `native_proof_regressions` contribute its other 261. | |
| while read -r package suite bound; do | |
| [ -n "$package" ] || continue | |
| skips="" | |
| while read -r xpkg xsuite xtest; do | |
| [ -n "$xpkg" ] || continue | |
| if [ "$xpkg" = "$package" ] && [ "$xsuite" = "$suite" ]; then | |
| skips="$skips --skip $xtest" | |
| echo "::notice::skipping known failure $package::$suite::$xtest" | |
| fi | |
| done <<< "$EXCLUSIONS" | |
| echo "::group::cargo test -p $package --test $suite" | |
| # Per-suite wall-clock bound: a hung compile must not eat the whole | |
| # job budget and hide the other suites' results. Mapped in-process | |
| # suites carry a much tighter bound than a diff-named one that | |
| # shells out to `perry compile` — see ci_e2e_scope.py. | |
| # shellcheck disable=SC2086 | |
| if ! timeout "${bound:-1500}" cargo test -p "$package" --test "$suite" -- $skips; then | |
| echo "::error::integration suite failed: $package --test $suite" | |
| status=1 | |
| fi | |
| echo "::endgroup::" | |
| done <<< "$SUITES" | |
| # #7708: the exclusions are self-invalidating. A held-out test that | |
| # now PASSES (or that no longer exists under that name) fails the job, | |
| # so a fix cannot land while leaving its entry behind — the failure | |
| # mode #797 recorded for the parity skip-list. | |
| if printf '%s\n' "$SUITES" | grep -q '^perry-codegen '; then | |
| while read -r xpkg xsuite xtest; do | |
| [ -n "$xpkg" ] || continue | |
| echo "::group::known-failure check $xpkg::$xsuite::$xtest" | |
| out="$(timeout 300 cargo test -p "$xpkg" --test "$xsuite" -- --exact "$xtest" 2>&1 || true)" | |
| printf '%s\n' "$out" | |
| if ! printf '%s\n' "$out" | grep -q '1 failed'; then | |
| echo "::error::$xpkg::$xsuite::$xtest is listed in SUITE_EXCLUSIONS but did not fail (it passed, or no test matched that name). Delete its entry from SUITE_EXCLUSIONS in scripts/ci_e2e_scope.py and let the suite run it." | |
| status=1 | |
| fi | |
| echo "::endgroup::" | |
| done <<< "$EXCLUSIONS" | |
| fi | |
| exit "$status" | |
| # --------------------------------------------------------------------------- | |
| # Windows build gate | |
| # | |
| # Exists because Windows-only compile/link breaks previously shipped to main | |
| # unseen — every other job runs on ubuntu/macos. Two real examples that | |
| # landed through green required checks: an MSVC-only rustc error (E0308 on | |
| # the `ExitProcess` extern in crates/perry-runtime/src/process/env_misc.rs) | |
| # and an MSVC-only link error (LNK2019: `js_crypto_ed25519_verify` | |
| # unresolved when linking perry.exe — Unix linkers dead-strip the unused | |
| # extern, link.exe errors). Building `perry` LINKS perry.exe, so the LNK2019 | |
| # class is caught here, not just rustc errors. The perry-dev profile | |
| # (opt-level 1, no LTO) keeps a cold Windows build inside a PR-sized budget; | |
| # --release would be far too slow for per-PR CI. | |
| # --------------------------------------------------------------------------- | |
| windows-build: | |
| runs-on: windows-latest | |
| # Cold Windows builds are slow, and this workflow never runs on pushes to | |
| # main, so rust-cache only saves on the nightly cron (github.ref is | |
| # refs/heads/main for `schedule`) — PR runs after a quiet night can be | |
| # near-cold. 75 leaves headroom over a fully cold build while still | |
| # bounding a true hang (same reasoning as conformance-smoke's bump: a | |
| # timeout on a required-path job is a deterministic PR blocker). | |
| timeout-minutes: 75 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| # This job compiles PR-controlled build scripts / proc macros; | |
| # don't leave the workflow token in .git/config for them to read | |
| # (zizmor "artipacked", flagged by review on #6610). | |
| persist-credentials: false | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version-file: .node-version | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| # perry-dev (see Cargo.toml [profile.perry-dev]) trades peak optimization | |
| # for build speed. The package set covers the compiler binary (link | |
| # gate), the runtime/stdlib pair (the usual source of cfg(windows) | |
| # externs), their static-library wrappers (used by the parity smoke | |
| # below), and both Windows UI crates — the ones cargo-test's ubuntu | |
| # runner must exclude and therefore never compiles. | |
| - name: Build compiler + runtime + Windows UI crates (perry-dev) | |
| run: cargo build --profile perry-dev -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p perry-ui-windows -p perry-ui-windows-winui | |
| # #7356: the full perry-runtime --lib suite is green on Windows for the | |
| # first time (the SEH/longjmp transport, setjmp alignment, TZ, trim and | |
| # spawnSync fixes). Before that, unguarded eh_walker calls meant the | |
| # crate did not even COMPILE here and nothing noticed — this step is the | |
| # arm that keeps the suite green rather than letting it rot back to | |
| # "unmeasurable". Single-threaded for the same #1444 process-global-state | |
| # reason as the ubuntu leg (both its invocations set RUST_TEST_THREADS=1); | |
| # perry-dev profile so the test build shares the dependency artifacts the | |
| # build step above already produced instead of paying a second cold | |
| # dev-profile build of a ~340k-line crate. | |
| - name: perry-runtime unit tests (single-threaded, #7356) | |
| shell: bash | |
| run: RUST_TEST_THREADS=1 cargo test --profile perry-dev --lib -p perry-runtime | |
| # Small deterministic subset: verifies the Git Bash driver itself, | |
| # `.exe`/`.lib` discovery, native TEMP paths, compilation, execution, | |
| # and Node/Perry comparison on a real Windows host. | |
| - name: Windows parity harness smoke | |
| shell: bash | |
| run: | | |
| PERRY_SKIP_BUILD=1 \ | |
| PERRY_BIN="$PWD/target/perry-dev/perry.exe" \ | |
| PERRY_RUNTIME_DIR="$PWD/target/perry-dev" \ | |
| ./run_parity_tests.sh \ | |
| --suite node-suite \ | |
| --module process \ | |
| --filter process/env/access | |
| - name: Verify perry.exe VERSIONINFO resource | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $binary = 'target/perry-dev/perry.exe' | |
| $metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json | |
| $expectedVersion = ($metadata.packages | Where-Object name -eq 'perry').version | |
| $version = (Get-Item $binary).VersionInfo | |
| $expectedFields = @{ | |
| CompanyName = 'PerryTS' | |
| FileDescription = 'Perry native TypeScript compiler' | |
| OriginalFilename = 'perry.exe' | |
| ProductName = 'Perry' | |
| } | |
| foreach ($field in $expectedFields.Keys) { | |
| if ($version.$field -ne $expectedFields[$field]) { | |
| throw "$field missing or incorrect in ${binary}: '$($version.$field)'" | |
| } | |
| } | |
| if (-not $version.FileVersion.StartsWith($expectedVersion) -or | |
| -not $version.ProductVersion.StartsWith($expectedVersion)) { | |
| throw "VERSIONINFO does not match Cargo version $expectedVersion (file=$($version.FileVersion), product=$($version.ProductVersion))" | |
| } | |
| - name: Test COFF duplicate-symbol archive trimming | |
| shell: bash | |
| run: | | |
| set -o pipefail | |
| cargo test --profile perry-dev -p perry --bin perry \ | |
| coff_archive_dedup_drops_only_fully_provided_members 2>&1 | tee test-output.log | |
| grep -q 'test result: ok. 1 passed; 0 failed' test-output.log || { | |
| echo "::error::expected COFF dedup test did not run exactly once" | |
| exit 1 | |
| } | |
| # --------------------------------------------------------------------------- | |
| # GC write-barrier stress (optional / non-blocking) | |
| # | |
| # `crates/perry/tests/gc_write_barrier_stress.rs` runs compiled binaries | |
| # under the slowest GC configuration (PERRY_GC_FORCE_EVACUATE + | |
| # PERRY_GC_VERIFY_EVACUATION) to hunt a *rare* corruption window (#5029). | |
| # Those tests are ~200s each and nondeterministic by nature, so they are a | |
| # poor fit for the blocking per-PR `cargo-test` gate (one flake blocked | |
| # every unrelated PR). They are `#[ignore]`d there and run here instead. | |
| # | |
| # The write-barrier stress tests stay opt-in + informational | |
| # (`continue-on-error` on THEIR step, so a flake never fails the workflow). | |
| # | |
| # The job itself is no longer informational. It also runs the GC x | |
| # representation-selection stress matrix (`scripts/gc_repsel_matrix.sh`), | |
| # which IS a gate: until it existed, a representation could regress GC | |
| # correctness and no CI job would say a word. Three weaknesses were fixed | |
| # deliberately, and re-introducing any of them re-opens that hole: | |
| # 1. job-level `continue-on-error: true` is gone (a gate that cannot fail | |
| # is not a gate); it now sits on the legacy write-barrier step only; | |
| # 2. the `if:` no longer requires a `run-extended-tests` label, which is | |
| # why this job "skipped" on the representation PRs (#6911, #6925); | |
| # 3. it no longer runs write-barrier stress *only* — nothing about the | |
| # representation corpus was covered before. | |
| # | |
| # Cost split: a PR runs the 4-arm subset, whose arms all share one | |
| # compile-time environment, so the corpus is compiled ONCE and run four | |
| # times. push / schedule / workflow_dispatch run the full arm list as the | |
| # deeper net. NOTE: this job is not yet in branch protection's required | |
| # contexts — adding it there is what makes the gate blocking. | |
| # | |
| # ***`schedule` IS LOAD-BEARING IN THE `if:` BELOW (#7194).*** Without it this | |
| # job had NO main-line execution at all, and the hole is invisible from either | |
| # end on its own: | |
| # | |
| # * this workflow's `push:` trigger is TAGS ONLY — "Direct pushes to main do | |
| # NOT trigger tests", stated at the top of this file — so `push` in the | |
| # `if:` only ever means a release tag; | |
| # * the nightly cron, which the concurrency comment above calls "the only | |
| # backstop for integration-suite regressions a scoped PR run can't see", | |
| # fires as `schedule`, which the `if:` did not list. Measured: twelve | |
| # consecutive nightly `main` runs, `gc-stress` reported `skipped` in every | |
| # one. | |
| # | |
| # So between release tags, nothing in CI ran scripts/gc_repsel_matrix.sh on | |
| # `main` — and the matrix is the only place the `requires=move` | |
| # allocation-point arms execute over the representation corpus. That is how | |
| # test_gap_repsel_p4a3_ptr_numarray stayed red on ten arms for over a week | |
| # with no CI event to say so, forcing three separate PRs (#7193, #7233, #7196) | |
| # to hand-exonerate the same seventy cells. This is CLAUDE.md hazard 4 in its | |
| # purest form: the job existed, was correctly written, and its subject never | |
| # ran. scripts/gc_gate_wiring_check.py asserts this from `lint` so it cannot | |
| # silently come back. | |
| # --------------------------------------------------------------------------- | |
| gc-stress: | |
| if: >- | |
| github.event_name == 'push' || | |
| github.event_name == 'pull_request' || | |
| github.event_name == 'schedule' || | |
| github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 90 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Install clang | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y clang | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| # Single source of truth: .node-version at the repo root. Node is the | |
| # matrix oracle (we byte-diff against it), so the version is a | |
| # correctness input. scripts/gc_repsel_matrix.sh refuses to run when | |
| # the running node disagrees with the pin — a test the oracle cannot | |
| # run would drop out of the gate silently. | |
| node-version-file: .node-version | |
| - name: Build perry + runtime staticlibs (release) | |
| env: | |
| CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" | |
| run: | | |
| cargo build --release \ | |
| -p perry -p perry-runtime -p perry-stdlib \ | |
| -p perry-runtime-static -p perry-stdlib-static | |
| # GATING. Fails the job on any new untriaged red cell. Cells whose GC arm | |
| # was measurably inert are reported UNVERIFIED, never green (#6942, | |
| # #6946, #6950) — the script asserts liveness from the collector's own | |
| # PERRY_GC_TRACE / PERRY_GC_DIAG output rather than trusting the env var. | |
| - name: GC x representation-selection matrix (PR subset) | |
| if: github.event_name == 'pull_request' | |
| run: ./scripts/gc_repsel_matrix.sh --no-build --arms pr --json gc-repsel-matrix.json | |
| - name: GC x representation-selection matrix (full) | |
| if: github.event_name != 'pull_request' | |
| run: ./scripts/gc_repsel_matrix.sh --no-build --arms all --json gc-repsel-matrix.json | |
| # GATING, and deliberately so. CLAUDE.md's GC knob kill-policy requires | |
| # every GC knob to have an arm that exercises it; the #7154 instruments | |
| # (PERRY_GC_PROTECT_FROMSPACE, PERRY_GC_SCHEDULE_SEED) | |
| # would otherwise be dark knobs on the subsystem with this repo's worst | |
| # history of configuration rot. This asserts BOTH defaults — inert with | |
| # the knobs unset, live with them set — and refuses to pass unless the | |
| # stress arms (the rate-1 seeded schedule, and the #7254 RATE=1 + | |
| # VERIFY_EVACUATION pairing) | |
| # forced strictly more collections than the pressure-only arm, so it | |
| # cannot go green having run zero copying minors (the #6942 / #7024 / | |
| # #7025 failure mode). | |
| # The detection property itself is a required-gate unit test: | |
| # gc/tests/fromspace_protect.rs::quarantine_catches_a_planted_stale_from_space_deref. | |
| # Arms 1-3/5 use a fixture sized for ~1200 back-edge polls (not #7154's | |
| # 240k), pinned to every-poll candidacy | |
| # (PERRY_GC_SCHEDULE_ALLOC_KB=0). Arm 6 (#7728) is the budgeted one: | |
| # a realistic poll count at the SHIPPED default, which is the axis that | |
| # a ~1200-poll fixture structurally cannot see. | |
| - name: GC rooting-bug instruments (inert-when-off, live-when-on) | |
| run: ./scripts/gc_instrument_smoke.sh target/release/perry | |
| - name: Run GC write-barrier stress tests | |
| # Informational: these are ~200s nondeterministic corruption-window | |
| # hunts (#5029). Kept out of the gate so a flake never blocks a PR. | |
| continue-on-error: true | |
| env: | |
| # Match the cargo-test gate's linker workaround (lld SIGBUS on the | |
| # shared runner during large test links). | |
| CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" | |
| run: cargo test -p perry --test gc_write_barrier_stress -- --ignored | |
| - name: Upload matrix report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: gc-repsel-matrix | |
| path: gc-repsel-matrix.json | |
| if-no-files-found: ignore | |
| # --------------------------------------------------------------------------- | |
| # Compiler-output regression gate | |
| # | |
| # Retains HIR, pre/post-opt LLVM IR, assembly, benchmark output, runtime | |
| # counters, vectorization remarks, benchmark timing summaries, and FP | |
| # contraction evidence for the primary CPU benchmark plus numeric fixtures. | |
| # Fails when hot-loop structural contracts regress. | |
| # --------------------------------------------------------------------------- | |
| compiler-output-regression: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| env: | |
| RUSTC_WRAPPER: sccache | |
| # sccache on a LOCAL DISK cache, persisted as a single tarball via | |
| # actions/cache (see the "Cache sccache objects" step) — NOT the GitHub | |
| # Actions cache backend (SCCACHE_GHA_ENABLED). The GHA backend stores one | |
| # cache object per compilation unit; GitHub's cache service throttled / | |
| # LRU-evicted the thousands of tiny entries, so a full build wrote ~3.3k | |
| # objects (≈35 min of write time) yet the next run got ~0% Rust hits | |
| # (measured: 3 hits / 3209 misses, 613 write errors) — i.e. every run | |
| # recompiled the dependency graph cold. A single tarball'd disk cache | |
| # restores in one step and gives real cross-run hit rates. Note | |
| # SCCACHE_CACHE_SIZE is honoured by the disk backend (it was a silent | |
| # no-op under the GHA backend, which is why the old "2G" never mattered). | |
| SCCACHE_GHA_ENABLED: "false" | |
| SCCACHE_DIR: ${{ github.workspace }}/.sccache | |
| SCCACHE_CACHE_SIZE: "12G" | |
| CARGO_INCREMENTAL: "0" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Install sccache | |
| uses: mozilla-actions/sccache-action@v0.0.10 | |
| # Persist the sccache disk cache across runs. The github.job + | |
| # github.run_id key makes every run (including PRs) save its own fresh | |
| # entry — distinct per job so the three sccache jobs don't collide on | |
| # save — while the shared prefix restore-keys pull the most recent prior | |
| # cache from ANY of them. The object cache therefore warms continuously | |
| # and cross-pollinates instead of starting cold each run. | |
| - name: Cache sccache objects | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ github.workspace }}/.sccache | |
| key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }} | |
| restore-keys: | | |
| sccache-${{ runner.os }}-perry- | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Install clang | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y clang | |
| - name: Build compiler | |
| run: cargo build -p perry | |
| - name: Run harness unit tests | |
| run: python3 -m unittest tests.test_compiler_output_regression | |
| - name: Run native ABI evidence report unit tests | |
| run: python3 -m unittest tests.test_native_abi_evidence_report | |
| - name: Gate native-region proof compiler output | |
| run: | | |
| python3 scripts/compiler_output_regression.py suite \ | |
| --suite native-region-proof \ | |
| --perry target/debug/perry \ | |
| --benchmark-mode smoke \ | |
| --runs 1 \ | |
| --perf-counters off \ | |
| --gate \ | |
| --print-summary | |
| - name: Gate native-ABI proof compiler output | |
| run: | | |
| python3 scripts/compiler_output_regression.py suite \ | |
| --suite native-abi-proof \ | |
| --perry target/debug/perry \ | |
| --benchmark-mode smoke \ | |
| --runs 1 \ | |
| --perf-counters off \ | |
| --gate \ | |
| --print-summary | |
| - name: Gate typed feedback runtime evidence | |
| env: | |
| PERRY_BIN: ${{ github.workspace }}/target/debug/perry | |
| run: python3 -m unittest tests.test_typed_feedback_runtime_evidence | |
| - name: Gate positive vectorization compiler output | |
| run: | | |
| python3 scripts/compiler_output_regression.py capture \ | |
| --perry target/debug/perry \ | |
| --workload vectorized_buffer_transform \ | |
| --benchmark-mode smoke \ | |
| --runs 1 \ | |
| --perf-counters off \ | |
| --gate \ | |
| --print-summary | |
| - name: Gate HIR fact rewrite compiler output | |
| run: | | |
| python3 scripts/compiler_output_regression.py capture \ | |
| --perry target/debug/perry \ | |
| --workload hir_fact_rewrite \ | |
| --benchmark-mode smoke \ | |
| --runs 1 \ | |
| --perf-counters off \ | |
| --gate \ | |
| --print-summary | |
| - name: Gate FP contraction modes | |
| run: | | |
| python3 scripts/compiler_output_regression.py capture \ | |
| --perry target/debug/perry \ | |
| --workload fma_contract \ | |
| --benchmark-mode smoke \ | |
| --runs 1 \ | |
| --perf-counters off \ | |
| --gate \ | |
| --fp-contract=on \ | |
| --clang-arg=-march=haswell \ | |
| --expect-fma=on \ | |
| --out-dir target/compiler-output-regression/fma_contract-fp-contract-on | |
| python3 scripts/compiler_output_regression.py capture \ | |
| --perry target/debug/perry \ | |
| --workload fma_contract \ | |
| --benchmark-mode smoke \ | |
| --runs 1 \ | |
| --perf-counters off \ | |
| --gate \ | |
| --fast-math \ | |
| --fp-contract=off \ | |
| --clang-arg=-march=haswell \ | |
| --expect-fma=off \ | |
| --out-dir target/compiler-output-regression/fma_contract-fast-no-contract | |
| - name: Upload compiler-output artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: compiler-output-regression | |
| path: target/compiler-output-regression/ | |
| # --------------------------------------------------------------------------- | |
| # Representation-selection promotion census (#7106) | |
| # | |
| # Counts, per workload and PER REPRESENTATION, how many values got each | |
| # unboxed representation, and compares each count against a ratcheted floor | |
| # (benchmarks/repsel_census/baseline.json). | |
| # | |
| # Why this job exists: #7034 discovered by hand-instrumenting the compiler | |
| # that `Ptr<Shape>` promotes NOTHING on `batch.ts` — the object-heavy | |
| # workload it exists for — and that only 3 of 17 suite benchmarks promote a | |
| # single shape local each. Nothing in CI could have told anyone that. | |
| # | |
| # What it counts is CONSUMPTION, not selection. An analysis proving a value | |
| # and codegen emitting something for it are different events, and #7107 found | |
| # by reading IR that `batch.ts` proves two `Ptr<Shape>` values and applies | |
| # one: `totals` is proven, reported as a win, and keeps the guarded diamond at | |
| # every access site. `07_object_create` and `12_binary_trees` are worse -- they | |
| # report a promotion each while `PERRY_PTR_SHAPE_LOCALS=0` produces a | |
| # byte-identical object. So `ptr-shape` and `ptr-shape-consumed` are separate | |
| # columns with separate floors, and every unconsumed promotion must name the | |
| # mechanism that ate it (#7109 / #7115). | |
| # | |
| # Why it can fail (CLAUDE.md, "Four ways a gate can be unable to fail"): | |
| # floors alone would not be enough, because the honest floor for | |
| # `Ptr<Shape>` on real code is zero today and a zero floor can never go red. | |
| # The corpus therefore includes hand-written liveness fixtures whose | |
| # minimums live in `scripts/compiler_output_harness/repsel_census.py`, NOT in | |
| # the regenerable baseline, plus a corpus-wide assertion that no census key | |
| # reads zero everywhere. Verified by sabotage in both directions: each of | |
| # PERRY_PTR_SHAPE_LOCALS / PERRY_PTR_NUMARRAY_LOCALS / | |
| # PERRY_CANONICAL_I32_LOCALS / PERRY_CANONICAL_STR_LOCALS / | |
| # PERRY_INT_VALUED_LOCALS set to 0 turns this job red; the default build is | |
| # green. | |
| # | |
| # DELIBERATELY NOT a required status check on its first landing — a gate | |
| # that has never been green would block every open PR. Promoting it is a | |
| # follow-up, and CLAUDE.md failure mode (2) is what happens if that | |
| # follow-up is skipped. | |
| # --------------------------------------------------------------------------- | |
| repsel-census: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| env: | |
| RUSTC_WRAPPER: sccache | |
| SCCACHE_GHA_ENABLED: "false" | |
| SCCACHE_DIR: ${{ github.workspace }}/.sccache | |
| SCCACHE_CACHE_SIZE: "12G" | |
| CARGO_INCREMENTAL: "0" | |
| # The census only needs codegen (`--no-link`), never a linked binary, so | |
| # it cannot be fooled by a stale libperry_runtime.a. Auto-optimize is off | |
| # because it rebuilds runtime libs the census never links. | |
| PERRY_NO_AUTO_OPTIMIZE: "1" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Install sccache | |
| uses: mozilla-actions/sccache-action@v0.0.10 | |
| - name: Cache sccache objects | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ github.workspace }}/.sccache | |
| key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }} | |
| restore-keys: | | |
| sccache-${{ runner.os }}-perry- | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| # Verdict logic first, cheaply: if the census can't tell a regression | |
| # from a pass, the compile-based run below is not worth the minutes. | |
| - name: Census verdict self-test | |
| run: | | |
| python3 scripts/compiler_output_regression.py census-self-test | |
| python3 scripts/compiler_output_regression.py census-knob-isolation-self-test | |
| python3 scripts/compiler_output_regression.py census-determinism-self-test | |
| python3 scripts/compiler_output_regression.py census-temp-hygiene-self-test | |
| python3 -m unittest tests.test_repsel_census | |
| - name: Build compiler | |
| run: cargo build -p perry | |
| # #7131. Every object comparison in this job (and in every | |
| # representation-selection A/B this repo has taken) assumes the compiler | |
| # is a function of its inputs. On ELF it was not — for months, and only | |
| # on ELF, which is why macOS review never saw it. This runner is x86_64 | |
| # Linux, so it is the host that can actually observe a relapse. | |
| - name: Emission determinism | |
| run: | | |
| python3 scripts/compiler_output_regression.py census-determinism \ | |
| --perry target/debug/perry \ | |
| --repeat 2 \ | |
| --jobs 4 | |
| # #7144. The other consequence of content-addressing the `.ll`: workers | |
| # holding identical IR share the name, so #7135 stopped deleting it and | |
| # nothing else did — one leftover per distinct IR ever compiled. CI never | |
| # saw it (runner temp dirs are reclaimed) while developer machines | |
| # reached 29 GB. This step compiles with `TMPDIR` pointed at an empty | |
| # directory and asserts it is still empty afterwards; note that a | |
| # repeat-and-compare check would NOT have caught it, because identical | |
| # IR reuses the identical name. | |
| - name: Temp-directory hygiene | |
| run: | | |
| python3 scripts/compiler_output_regression.py census-temp-hygiene \ | |
| --perry target/debug/perry \ | |
| --repeat 2 \ | |
| --jobs 4 | |
| - name: Promotion census | |
| run: | | |
| python3 scripts/compiler_output_regression.py census \ | |
| --perry target/debug/perry \ | |
| --keep-reports target/repsel-census/reports \ | |
| --gate | |
| # Prove the gate's subject was live: with `Ptr<Shape>` selection | |
| # disabled the census MUST go red. A census that reports zero and exits | |
| # green is worth nothing, and this step is what stops that from being | |
| # possible. `!` because a green run here is the failure. | |
| - name: Sabotage check (the gate must be able to fail) | |
| run: | | |
| set +e | |
| out="$(python3 scripts/compiler_output_regression.py census \ | |
| --perry target/debug/perry \ | |
| --env PERRY_PTR_SHAPE_LOCALS=0 \ | |
| --gate 2>&1)" | |
| status=$? | |
| set -e | |
| printf '%s\n' "$out" | |
| # Exit 1 is the gate's verdict; exit 2 is a harness error (a compile | |
| # that fell over, a schema mismatch). Only the former proves the | |
| # census observed the sabotage, so insist on the code AND the reason. | |
| if [ "$status" -ne 1 ]; then | |
| echo "::error::Sabotage run exited $status, expected 1 (a gate verdict)." | |
| echo "::error::Exit 0 means the census cannot see Ptr<Shape> promotion at all." | |
| exit 1 | |
| fi | |
| if ! printf '%s' "$out" | grep -q "fixture_ptr_shape: ptr-shape promoted 0"; then | |
| echo "::error::Sabotage run failed for some reason OTHER than the" | |
| echo "::error::ptr-shape fixture losing its promotion. The gate is red," | |
| echo "::error::but not for the reason that proves its subject was live." | |
| exit 1 | |
| fi | |
| # The consumed column is a SEPARATE counter fed from separate codegen | |
| # sites, so it needs its own liveness assertion. A `ptr-shape-consumed` | |
| # that stayed at its floor while `ptr-shape` went to zero would be a | |
| # number disconnected from the compiler -- and it is the column the | |
| # performance claims now rest on. | |
| if ! printf '%s' "$out" | grep -q "fixture_ptr_shape: ptr-shape-consumed promoted 0"; then | |
| echo "::error::ptr-shape went to zero but ptr-shape-consumed did not." | |
| echo "::error::The consumption counter is not tracking the compiler." | |
| exit 1 | |
| fi | |
| if ! printf '%s' "$out" | grep -q "CONSUMPTION SITE NEVER EXERCISED"; then | |
| echo "::error::No consumption site went dark with Ptr<Shape> disabled." | |
| echo "::error::The per-site coverage gate is not tracking the compiler:" | |
| echo "::error::two of the six recorders had never fired before it existed." | |
| exit 1 | |
| fi | |
| # #7034 §3 (the array-element escape) is a SEPARATE analysis | |
| # (collectors/ptr_shape_elements.rs) behind the same knob, and NO | |
| # real corpus workload promotes an element local -- so if it stopped | |
| # issuing facts entirely, every assertion above would still pass and | |
| # this job would stay green. Its own fixture is what makes that | |
| # visible. | |
| if ! printf '%s' "$out" | grep -q "fixture_ptr_shape_elements: ptr-shape-consumed promoted 0"; then | |
| echo "::error::The array-element fixture kept its promotions with" | |
| echo "::error::Ptr<Shape> disabled. Either the element analysis is not" | |
| echo "::error::behind the knob, or the fixture stopped exercising it." | |
| exit 1 | |
| fi | |
| echo "Census correctly went red with PERRY_PTR_SHAPE_LOCALS=0." | |
| - name: Upload census reports | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: repsel-census | |
| path: target/repsel-census/ | |
| # --------------------------------------------------------------------------- | |
| # Native ABI evidence packet | |
| # | |
| # Full material-performance packet for the type-lowering gate. This is heavier | |
| # than the per-PR compiler-output smoke because it runs the native-ABI proof | |
| # packet with timing-quality samples, runtime checks, and release/LTO symbol | |
| # freshness. Gate tag pushes and opt-in PR/manual runs; ordinary PRs rely on | |
| # the lighter report/unit and compiler-output structural gates above. | |
| # --------------------------------------------------------------------------- | |
| native-abi-evidence-packet: | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) || | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 90 | |
| env: | |
| RUSTC_WRAPPER: sccache | |
| SCCACHE_GHA_ENABLED: "false" | |
| SCCACHE_DIR: ${{ github.workspace }}/.sccache | |
| SCCACHE_CACHE_SIZE: "12G" | |
| CARGO_INCREMENTAL: "0" | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Install sccache | |
| uses: mozilla-actions/sccache-action@v0.0.10 | |
| - name: Cache sccache objects | |
| uses: actions/cache@v6 | |
| with: | |
| path: ${{ github.workspace }}/.sccache | |
| key: sccache-${{ runner.os }}-perry-native-abi-evidence-${{ github.run_id }} | |
| restore-keys: | | |
| sccache-${{ runner.os }}-perry- | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Install clang | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y clang | |
| - name: Gate native ABI evidence packet | |
| env: | |
| RUSTC_WRAPPER: "" | |
| RUSTFLAGS: -Awarnings | |
| run: | | |
| PYTHON=python3 bash tests/test_native_abi_evidence_packet_smoke.sh \ | |
| target/native-abi-evidence-packet | |
| - name: Upload native ABI evidence packet | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: native-abi-evidence-packet | |
| path: target/native-abi-evidence-packet/ | |
| # --------------------------------------------------------------------------- | |
| # Gap-suite conformance smoke (every PR + main push) | |
| # --------------------------------------------------------------------------- | |
| conformance-smoke: | |
| # 2026-07-02 audit §11: nothing on the default PR path exercised | |
| # TypeScript SEMANTICS — lint/cargo-test/api-docs-drift build and unit- | |
| # test the compiler but never diff a compiled program against node, so | |
| # behavioral regressions landed silently between tags (the 2026-06-23 | |
| # 851-case test262 regression and the #5763 setPrototypeOf boot breakage | |
| # both shipped through green required checks). This job runs the gap | |
| # suite — every test-files/test_gap_*.ts AOT-compiled and diffed | |
| # byte-for-byte against `node --experimental-strip-types` — and fails on | |
| # any failure NOT already triaged in test-parity/known_failures.json | |
| # (run_gap_tests.sh's no-new-untriaged gate). | |
| # | |
| # THE ORACLE VERSION IS LOAD-BEARING. Node is what we diff against, so a | |
| # test whose feature the pinned Node lacks makes *node* exit non-zero, the | |
| # harness classifies it `node_fail`, and the test is dropped from the gate | |
| # entirely — a silent hole, not a red build. This job sat on Node 22 while | |
| # the suite grew Node 24/26 features, which hid 14 tests (all of Temporal, | |
| # DisposableStack, Float16Array, Uint8Array base64/hex). The pin now lives | |
| # in .node-version so it can't drift out from under the suite again; raise | |
| # it deliberately, and re-measure the delta when you do (see #6364). | |
| # | |
| # Sharded across 8 parallel runners (--shard N/8) so the ~258-test suite | |
| # runs in ~1/8 the wall-time — the 258 sequential Perry compiles dominate | |
| # the cost and split evenly, taking the single-job ~57 min down to ~15 min | |
| # per shard. The fan-in job `conformance-smoke-complete` below is the ONE | |
| # status branch protection requires; a single shard's red bubbles up | |
| # through it. Each shard's no-new-untriaged gate (run_gap_tests.sh) covers | |
| # only its own slice, which is exactly the right per-shard semantics. | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3, 4, 5, 6, 7, 8] | |
| if: github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| # Per-shard wall time has crept up as the gap suite grew: the original | |
| # sharded run was 19-37 min/shard, but 2026-07-16 measured 33-44 min for | |
| # shards 2-8 and shard 1 (its slice is the heaviest) hit 55:18 — the exact | |
| # 55-min cap — and CANCELLED on every run, incl. `gh run rerun --failed`. | |
| # On slow-runner days a second shard (observed: shard 3) also grazed 55. | |
| # Because `conformance-smoke-complete` is a required context, that | |
| # deterministic timeout flaky-red-blocked every PR (#6456). Bump to 75 for | |
| # comfortable headroom (~35 min over the normal-day slowest, ~20 over the | |
| # heavy shard) while still bounding a genuine hang. Durable fix — raising | |
| # the shard count 8->12 so no single slice approaches the cap — tracked as | |
| # a follow-up on #6456. | |
| timeout-minutes: 75 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| # Single source of truth: .node-version at the repo root. Node is the | |
| # gap/parity oracle (we byte-diff against it), so the version is a | |
| # correctness input, not an incidental toolchain detail — never pin it | |
| # inline here. See CLAUDE.md ("TypeScript Parity Status"). | |
| node-version-file: .node-version | |
| - name: Run gap suite (shard ${{ matrix.shard }}/8) | |
| run: ./scripts/run_gap_tests.sh --shard ${{ matrix.shard }}/8 | |
| - name: Upload gap report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: gap-suite-report-shard-${{ matrix.shard }} | |
| path: test-parity/reports/ | |
| # --------------------------------------------------------------------------- | |
| # Fan-in for the sharded gap suite. `needs.conformance-smoke.result` is | |
| # "success" only when EVERY shard succeeded, so this single context is a | |
| # faithful all-shards-green gate — the one branch protection requires. | |
| # `if: always()` guarantees it runs and reports even when a shard fails | |
| # (a required check that got *skipped* would otherwise wedge the PR on | |
| # "Expected"); the event guard mirrors the shards so it no-ops off the PR | |
| # path just like they do. | |
| # --------------------------------------------------------------------------- | |
| conformance-smoke-complete: | |
| needs: conformance-smoke | |
| if: always() && (github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'workflow_dispatch') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Require every gap-suite shard to pass | |
| run: | | |
| result='${{ needs.conformance-smoke.result }}' | |
| echo "conformance-smoke (all 8 shards) => $result" | |
| if [[ "$result" != "success" ]]; then | |
| echo "::error::gap suite failed, was cancelled, or was skipped in one or more shards" | |
| exit 1 | |
| fi | |
| echo "All gap-suite shards passed." | |
| # --------------------------------------------------------------------------- | |
| # Parity tests (Perry output vs Node.js) | |
| # --------------------------------------------------------------------------- | |
| parity: | |
| # Release-publish decoupling: aspirational extended suite. Per maintainer | |
| # decision it no longer BLOCKS package publishing — release-packages.yml's | |
| # await-tests gate keys on this workflow's run conclusion, and job-level | |
| # `continue-on-error: true` keeps a red result here from failing that | |
| # conclusion. The job still runs on every tag + shows its own pass/fail as | |
| # an informational signal (and core jobs — cargo-test/lint/api-docs-drift/ | |
| # compiler-output-regression — still gate publish). | |
| continue-on-error: true | |
| # Was macos-14 — moved to ubuntu-latest in v0.5.392. Parity tests | |
| # just compare Perry's stdout against `node --experimental-strip-types`'s | |
| # stdout per test file; both run cleanly on Linux. `gtimeout` on | |
| # macOS is `timeout` on Linux (the run_parity_tests.sh wrapper | |
| # detects either). Node 22+ is installed via setup-node@v4 below. | |
| # 10× billing weight cut. | |
| # | |
| # v0.5.1018: gated to tag pushes only (`github.event_name == 'push'`). | |
| # The pull_request trigger above still fires the workflow on PRs for | |
| # the lint / cargo-test / api-docs-drift gates, but parity now only | |
| # runs on release tags. Direct main commits + PR cycle no longer pay | |
| # the ~20 min parity bill; release tagging still catches regressions | |
| # before publish (release-packages.yml await-tests gate waits on | |
| # this job by name for tag events). | |
| # | |
| # Opt-in: apply the `run-extended-tests` label to a PR, or dispatch | |
| # the workflow manually with `run_extended_tests=true`, to run this | |
| # job on demand. PR authors and maintainers can both apply labels. | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) || | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| # Single source of truth: .node-version at the repo root. Node is the | |
| # gap/parity oracle (we byte-diff against it), so the version is a | |
| # correctness input, not an incidental toolchain detail — never pin it | |
| # inline here. See CLAUDE.md ("TypeScript Parity Status"). | |
| node-version-file: .node-version | |
| - name: Build compiler | |
| run: cargo build --release | |
| - name: Run parity tests | |
| run: ./run_parity_tests.sh | |
| - name: Check parity threshold | |
| run: | | |
| set +e | |
| python3 scripts/parity_threshold_gate.py \ | |
| --check \ | |
| --output-json test-parity/reports/parity_threshold_latest.json \ | |
| --output-md test-parity/reports/parity_threshold_latest.md | |
| status=$? | |
| set -e | |
| cat test-parity/reports/parity_threshold_latest.md >> "$GITHUB_STEP_SUMMARY" | |
| exit "$status" | |
| # Bidirectional since #7582: red on a failure that is not allowed here, | |
| # AND red on an allowlist entry whose test ran on this platform and | |
| # PASSED. The second direction is the one that matters — the file was a | |
| # pure suppression list, so a fixed test kept its entry forever and the | |
| # entry silently absorbed that test's next regression (#7580). | |
| - name: Check for new and stale failures | |
| run: >- | |
| python3 scripts/parity_known_failures.py | |
| --report test-parity/reports/latest.json | |
| --known test-parity/known_failures.json | |
| - name: Generate parity matrix trend | |
| run: | | |
| python3 scripts/parity_matrix_trend.py \ | |
| --check \ | |
| --output-json test-parity/reports/parity_matrix_latest.json \ | |
| --output-md test-parity/reports/parity_matrix_latest.md | |
| cat test-parity/reports/parity_matrix_latest.md >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload parity report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: parity-report | |
| path: | | |
| test-parity/reports/latest.json | |
| test-parity/reports/parity_threshold_latest.json | |
| test-parity/reports/parity_threshold_latest.md | |
| test-parity/reports/parity_matrix_latest.json | |
| test-parity/reports/parity_matrix_latest.md | |
| # Capture per-test compile stderr written by run_parity_tests.sh into | |
| # `test-parity/output/*.compile_error.log` so the long-tail | |
| # macOS-14-only compile failures (tracked as `ci-env` in | |
| # known_failures.json) can finally be diagnosed by reading the actual | |
| # error message rather than inferring from the test family. | |
| - name: Upload compile-error logs | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: parity-compile-errors-${{ runner.os }} | |
| path: test-parity/output/*.compile_error.log | |
| if-no-files-found: ignore | |
| # --------------------------------------------------------------------------- | |
| # Compile smoke test (all 130+ test files must compile) | |
| # --------------------------------------------------------------------------- | |
| compile-smoke: | |
| # Release-publish decoupling (see `parity` above): aspirational extended | |
| # suite, informational only — does not block package publishing. | |
| continue-on-error: true | |
| # Was macos-14 — moved to ubuntu-latest in v0.5.392. The smoke | |
| # compiles every `test-files/*.ts` with the bare `perry foo.ts -o | |
| # out` path; the auto-optimize cache + clang link steps work | |
| # identically on Linux. The v0.5.385 sha256 sidecar is portable | |
| # via `command -v sha256sum || shasum -a 256`. `xargs -P` is | |
| # GNU on Linux (the macos-14 BSD xargs we tuned for behaves the | |
| # same for our usage). Linux runners are 4-vCPU vs macos-14's 3, | |
| # so could try NJOBS=4, but keeping NJOBS=3 + retry conservatively | |
| # for the cargo auto-optimize race (issue tracked separately). | |
| # 10× billing weight cut. | |
| # | |
| # v0.5.1018: gated to tag pushes only (`github.event_name == 'push'`). | |
| # See the parity job comment above for rationale — release-packages.yml | |
| # still requires this job on tag events before publishing. | |
| # | |
| # Opt-in: `run-extended-tests` PR label or `workflow_dispatch` with | |
| # `run_extended_tests=true` runs this job on demand. | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) || | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Build compiler | |
| run: cargo build --release | |
| - name: Issue #945 scalar method IR guard | |
| run: | | |
| PERRY_BIN="$PWD/target/release/perry" \ | |
| scripts/run_issue_945_scalar_method_ir_guard.sh | |
| - name: Compile all test files | |
| run: | | |
| set -uo pipefail | |
| export PERRY="$PWD/target/release/perry" | |
| export LOGS_DIR="/tmp/perry_smoke_logs" | |
| # Tests that are known to not compile cleanly under the bare | |
| # `perry foo.ts -o out` smoke path. Sources: | |
| # - test_ui_*: need `--target macos` / `ios-simulator` to pull | |
| # in libperry_ui_*; the no-target compile path doesn't link | |
| # the platform widgets. | |
| # - test_timer: hangs on the runtime event loop under the | |
| # no-arg compile path. | |
| # | |
| # The 11-entry Buffer/typed-array `ci-env` skip family that | |
| # previously lived here (test_gap_buffer_ops, test_buffer_*, | |
| # test_inline_uint8array_param, test_issue_167_*, | |
| # test_issue_227_*, test_gap_fetch_response, | |
| # test_issue_234_blob_methods, etc.) has been removed as of | |
| # PR #239: the actual root cause was a missing | |
| # `module.declare_function("llvm.assume", VOID, &[I1])` in | |
| # `crates/perry-codegen/src/runtime_decls.rs`. Apple Clang | |
| # ≥21 (Xcode 26 / local) auto-recognised the intrinsic; | |
| # Apple Clang 15 (macos-14 runner / Xcode 15.x / LLVM 17) | |
| # errored with `error: use of undefined value '@llvm.assume'`. | |
| # Space-separated skip list (associative arrays require bash | |
| # 4+; macOS-14 ships bash 3.2). Word-boundary match keeps the | |
| # substring lookup safe. | |
| # test_phase2v3_3_show_toast_set_text imports `setText` and | |
| # `showToast` from perry/ui. The cross-platform stubs in | |
| # perry-runtime/src/ui_text_registry.rs ARE meant to make the | |
| # bare-target compile path work (no `--target` flag → host | |
| # build), but the macOS doc-tests + this Linux compile-smoke | |
| # both still fail at link time because the runtime registers | |
| # the macOS-side handler in `perry-ui-macos/src/app_run.rs`, | |
| # which is only linked when `needs_ui = true`. Without | |
| # `--target macos`, perry-ui-macos isn't pulled in, and the | |
| # cross-platform stubs route to a NULL handler → undefined | |
| # symbol. The fix is a separate coordination work item with | |
| # the v3.3 toast/setText worker (PR #322 family); skip-listing | |
| # here unblocks the v9 CI smoke landing without papering over | |
| # the underlying gap. | |
| # test_ramda_user_import intentionally imports a user package | |
| # (`ramda`) through the V8 fallback path. The compile-smoke | |
| # runner does not npm-install optional package fixtures, so the | |
| # strict unresolved-namespace diagnostic is expected here. | |
| # test_take_screenshot imports perry/ui (same `--target gtk4` | |
| # / pre-built libperry_ui_gtk4.a coupling as the test_ui_* | |
| # family above — bare-target compile path doesn't link the | |
| # platform widgets on Linux). | |
| # test_issue_842_side_effect_dynamic_import compiles a barrel | |
| # file that dynamic-imports a sibling helper; the smoke | |
| # harness compiles each .ts standalone with `perry foo.ts -o | |
| # out`, so the helper .o never gets produced and ld fails on | |
| # the unresolved symbol. The test belongs in a multi-file | |
| # integration runner, not the per-file smoke pass. | |
| # test_jose_signverify_roundtrip references `jwtVerify` from | |
| # the jose ext (#1025 sibling work). The bare smoke compile | |
| # path doesn't link the jose-specific runtime symbol — same | |
| # ld-unresolved-reference failure observed on #1038 pre-merge. | |
| # Move to a jose-aware test runner once that crate's CI hook | |
| # is wired up. | |
| # test_ui_adbanner_smoke (#867 AdBanner widget), | |
| # test_ui_on_keydown_smoke / test_issue_1495_image_systemname / | |
| # test_issue_1867_audio_playback / test_issue_2022_canvas_draw_image | |
| # all import `perry/ui` (App/AdBanner/Image/Canvas/loadImage/keydown). Like the | |
| # rest of the test_ui_* / media family above they need `--target | |
| # macos` to link the platform widgets; the bare `perry foo.ts -o out` | |
| # smoke path doesn't pull in libperry_ui_* on Linux, so they fail with | |
| # ld undefined-symbol errors. ci-env, not a Perry codegen bug. | |
| export SKIP_TESTS=" \ | |
| test_ui_comprehensive \ | |
| test_ui_controls \ | |
| test_ui_phase4 \ | |
| test_ui_adbanner_smoke \ | |
| test_ui_on_keydown_smoke \ | |
| test_issue_1495_image_systemname \ | |
| test_issue_1867_audio_playback \ | |
| test_issue_2022_canvas_draw_image \ | |
| test_timer \ | |
| test_phase2v3_3_show_toast_set_text \ | |
| test_issue_351_media_playback \ | |
| test_issue_442_inline_button_bg \ | |
| test_issue_538_background_tasks \ | |
| test_issue_553_mobile_widgets \ | |
| test_issue_556_table_array \ | |
| test_issue_556_table_concat \ | |
| test_issue_610_foreach \ | |
| test_issue_610_smoke \ | |
| test_issue_640_navstack_textfield \ | |
| test_issue_763_reactive_textfield \ | |
| test_issue_764_state_at_module_init \ | |
| test_ramda_user_import \ | |
| test_take_screenshot \ | |
| test_issue_842_side_effect_dynamic_import \ | |
| test_jose_signverify_roundtrip \ | |
| test_parity_assert \ | |
| test_parity_async_hooks \ | |
| test_parity_buffer \ | |
| test_parity_child_process \ | |
| test_parity_cluster \ | |
| test_parity_crypto \ | |
| test_parity_dgram \ | |
| test_parity_diagnostics_channel \ | |
| test_parity_decimal \ | |
| test_parity_dns \ | |
| test_parity_dns_promises \ | |
| test_parity_dotenv \ | |
| test_parity_events \ | |
| test_parity_fs \ | |
| test_parity_fs_promises \ | |
| test_parity_http \ | |
| test_parity_http2 \ | |
| test_parity_https \ | |
| test_parity_lodash \ | |
| test_parity_module \ | |
| test_parity_moment \ | |
| test_parity_net \ | |
| test_parity_path \ | |
| test_parity_perf_hooks \ | |
| test_parity_process \ | |
| test_parity_querystring \ | |
| test_parity_readline \ | |
| test_parity_readline_promises \ | |
| test_parity_stream \ | |
| test_parity_stream_consumers \ | |
| test_parity_stream_promises \ | |
| test_parity_stream_web \ | |
| test_parity_sys \ | |
| test_parity_test \ | |
| test_parity_timers \ | |
| test_parity_timers_promises \ | |
| test_parity_tls \ | |
| test_parity_url \ | |
| test_parity_util \ | |
| test_parity_validator \ | |
| test_parity_worker_threads \ | |
| test_parity_zlib " | |
| rm -rf "$LOGS_DIR" | |
| mkdir -p "$LOGS_DIR" | |
| # Worker function. Each test owns a unique marker filename | |
| # (.pass / .fail / .skip) under $LOGS_DIR, so concurrent | |
| # workers never race on the same file. Counts + failure list | |
| # are aggregated below AFTER all workers finish — no shared | |
| # bash-counter state crosses subshell boundaries. Per-test | |
| # stderr is captured to $LOGS_DIR/<name>.compile_error.log so | |
| # the artifact upload step preserves the actual error | |
| # messages (long-tail macOS-14 codegen failures are tracked | |
| # as `ci-env` in test-parity/known_failures.json and are | |
| # otherwise diagnosed by inference, not data). | |
| compile_one() { | |
| local f="$1" | |
| [[ -d "$f" ]] && return 0 | |
| local name | |
| name=$(basename "$f" .ts) | |
| if [[ "$SKIP_TESTS" == *" $name "* ]]; then | |
| : > "$LOGS_DIR/${name}.skip" | |
| return 0 | |
| fi | |
| local err_log="$LOGS_DIR/${name}.compile_error.log" | |
| # Try once. If perry compile fails, sleep 2s then retry. | |
| # The xargs -P parallel pass can race when two workers both | |
| # need an auto-optimize rebuild of the same feature combo — | |
| # the loser's clang sees a momentarily-missing | |
| # `target/perry-auto-<hash>/release/libperry_runtime.a` and | |
| # bails with `errno=2`. Race window is sub-second so a | |
| # single retry after a brief delay is the cheapest fix | |
| # without serializing the workers entirely. `||` short- | |
| # circuits on first success — the success path is unchanged. | |
| try_compile() { | |
| "$PERRY" "$f" -o "/tmp/perry_smoke_${name}" 2>"$err_log" | |
| } | |
| try_compile && status=0 || status=$? | |
| if [[ $status -ne 0 ]]; then | |
| # Retry-on-race semantics: the xargs -P parallel pass can | |
| # race when two workers both need an auto-optimize rebuild | |
| # of the same feature combo — the loser's clang sees a | |
| # momentarily-missing libperry_runtime.a and bails. A single | |
| # retry after a brief delay is the cheapest fix. | |
| sleep 2 | |
| try_compile && status=0 || status=$? | |
| fi | |
| if [[ $status -eq 0 ]]; then | |
| : > "$LOGS_DIR/${name}.pass" | |
| rm -f "/tmp/perry_smoke_${name}" "$err_log" | |
| else | |
| : > "$LOGS_DIR/${name}.fail" | |
| fi | |
| } | |
| export -f compile_one | |
| # ubuntu-latest runners have 4 vCPUs; perry compile is | |
| # CPU-bound for HIR/codegen but waits on the linker (clang) | |
| # for a meaningful chunk of each test. v0.5.384 dropped to | |
| # NJOBS=3 after NJOBS=6 hit a cargo auto-optimize file-lock | |
| # race: two workers rebuilding the same `target/perry-auto- | |
| # <hash>/lib*.a` led to one worker's clang seeing `errno=2` | |
| # mid-link. v0.5.429 closed that race at the source via an | |
| # OS file lock in `commands/compile/optimized_libs.rs:: | |
| # build_optimized_libs` (fslock dep — flock on Unix, | |
| # LockFileEx on Windows; serializes per-hash, parallel across | |
| # different hashes). NJOBS=6 is now safe again. Sequential | |
| # baseline was ~26 min; NJOBS=3 → ~10-12 min; NJOBS=6 → ~6-8 | |
| # min on a 4-vCPU runner. The retry-once in compile_one stays | |
| # as a belt-and-suspenders safety net for any remaining race | |
| # corner the lock doesn't catch. | |
| NJOBS="${PERRY_SMOKE_JOBS:-6}" | |
| printf '%s\n' test-files/*.ts \ | |
| | xargs -P "$NJOBS" -n 1 -I{} bash -c 'compile_one "$@"' _ {} | |
| # Count markers via shopt nullglob + bash array length. Pre-fix | |
| # we used `ls -1 "$LOGS_DIR"/*.fail | wc -l` which fails when | |
| # no matches exist (`ls` exits 1 on missing files), and with | |
| # GH Actions' default `bash -eo pipefail` the failed pipe | |
| # propagates errexit and kills the script BEFORE printing the | |
| # summary line — so a clean run with zero failures still made | |
| # compile-smoke exit 1 (PR #285 / v0.5.379 introduced this). | |
| # nullglob makes an empty glob expand to nothing instead of | |
| # the literal pattern, so the array length is correctly 0. | |
| shopt -s nullglob | |
| pass_files=("$LOGS_DIR"/*.pass) | |
| fail_files=("$LOGS_DIR"/*.fail) | |
| skip_files=("$LOGS_DIR"/*.skip) | |
| PASS=${#pass_files[@]} | |
| FAIL=${#fail_files[@]} | |
| SKIP=${#skip_files[@]} | |
| echo "Compile smoke: $PASS passed, $FAIL failed, $SKIP skipped" | |
| if [[ $FAIL -gt 0 ]]; then | |
| echo "Compile failures:" | |
| for marker in "$LOGS_DIR"/*.fail; do | |
| [[ -e "$marker" ]] || continue | |
| name=$(basename "$marker" .fail) | |
| echo " - $name" | |
| # Surface the head of each failure log directly in the job | |
| # output so a quick scan reveals the underlying error | |
| # without downloading the artifact. | |
| err_log="$LOGS_DIR/${name}.compile_error.log" | |
| if [[ -s "$err_log" ]]; then | |
| echo " --- compile stderr (first 30 lines) ---" | |
| head -n 30 "$err_log" | sed 's/^/ /' | |
| echo " --- end ---" | |
| fi | |
| done | |
| exit 1 | |
| fi | |
| - name: Upload compile-smoke error logs | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: compile-smoke-error-logs | |
| path: /tmp/perry_smoke_logs/*.compile_error.log | |
| if-no-files-found: ignore | |
| # Thread-primitive compile-error tests (#146): checks that closures | |
| # passed to perry/thread primitives with outer-variable writes are | |
| # rejected. The runtime thread_primitives.ts example is covered by | |
| # the doc-tests job below. | |
| - name: Thread-primitive compile-error tests | |
| run: ./scripts/run_thread_tests.sh | |
| # perry/ui styling-matrix CI gate (Phase A of issue #185): verifies | |
| # crates/perry-ui/src/styling_matrix.rs is in sync with every backend's | |
| # lib.rs FFI exports, regenerates docs/src/ui/styling-matrix.md, then | |
| # `git diff --exit-code` catches a forgotten-to-commit regeneration. | |
| # Drift fails CI loudly so a future FFI add/remove can't silently | |
| # land without a matrix update. | |
| - name: UI styling matrix | |
| run: | | |
| ./scripts/run_ui_styling_matrix.sh | |
| git diff --exit-code -- docs/src/ui/styling-matrix.md \ | |
| || (echo "docs/src/ui/styling-matrix.md regenerated; commit the diff" && exit 1) | |
| # Visual styling test ↔ spec consistency (#185 follow-up). | |
| # `docs/examples/ui/styling/visual_test.ts` is the canonical | |
| # comprehensive visual test app; `visual_test.spec.md` documents | |
| # each cell's expected visible signature for human/LLM-aided | |
| # screenshot verification. The two files must stay in lockstep | |
| # — adding a row to the .ts without updating the spec silently | |
| # breaks the verification flow. The actual cross-platform | |
| # compile-test of visual_test.ts is handled by the existing | |
| # run_doc_tests.sh loop downstream. | |
| - name: Visual styling test ↔ spec consistency | |
| run: ./scripts/run_visual_test_check.sh | |
| # Fastify end-to-end integration (#174): launches a Perry-compiled | |
| # Fastify server as a background process, curls four routes covering | |
| # simple GET, path params, POST with JSON body + reply.code(), and | |
| # 404 fallback. The docs Fastify example is marked no-test because | |
| # app.listen() blocks forever; this script provides the coverage | |
| # that the no-test tag would otherwise hide. | |
| - name: Fastify integration tests | |
| run: ./scripts/run_fastify_tests.sh | |
| # Memory-stability regression suite. Two failure modes microbenchs | |
| # don't catch: (1) slow RSS accumulation across 100k-200k iterations | |
| # of allocate-and-discard (would catch a future block-pinning / | |
| # cache-leak / tenuring-trap regression in the gen-GC work), and | |
| # (2) crashes when gc() is forced aggressively during JSON parse, | |
| # deep recursion, or closure init. Each test runs under default, | |
| # PERRY_GEN_GC=1, and PERRY_GEN_GC=1 PERRY_WRITE_BARRIERS=1 so a | |
| # regression in any GC mode is caught. Linux-only because /usr/bin/time | |
| # availability + RSS reporting differs on Windows runners. | |
| - name: Memory stability tests | |
| if: runner.os == 'Linux' || runner.os == 'macOS' | |
| env: | |
| PERRY_GC_EVIDENCE_DIR: ${{ runner.temp }}/gc-evidence | |
| run: ./scripts/run_memory_stability_tests.sh | |
| - name: Upload GC evidence artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: gc-evidence-${{ runner.os }} | |
| path: ${{ runner.temp }}/gc-evidence | |
| if-no-files-found: ignore | |
| # --------------------------------------------------------------------------- | |
| # HarmonyOS ArkUI codegen smoke (Phase 2 v9). | |
| # | |
| # The harmonyos compile path produces a 3-part output: the .so (LLVM | |
| # codegen, just like every other backend), the ArkUI Index.ets (emitted | |
| # by perry-codegen-arkts from the harvested perry/ui App({...}) call), | |
| # and the NAPI bridge declarations. End-to-end `perry compile --target | |
| # harmonyos` requires the OpenHarmony SDK (clang + musl sysroot, ~600 | |
| # MB) which isn't pre-installed on ubuntu-latest runners and isn't | |
| # worth downloading every CI run. | |
| # | |
| # What CI CAN cover without the SDK is the codegen-side ArkUI emission | |
| # — the part that's most likely to regress as Phase 2 widgets evolve. | |
| # `crates/perry-codegen-arkts/tests/phase2_full_app_smoke.rs` is the | |
| # comprehensive integration test: constructs a single Module that uses | |
| # every Phase 2 widget shape (state<T>, Tabs, Menu, Grid, LazyVStack | |
| # with .map, ForEach via array.map, inline style: { } with animation/ | |
| # shadow/textDecoration, @app.media image, Toggle/TextField/Slider with | |
| # multi-arg invokeCallback1 closures) and asserts the emitted Index.ets | |
| # contains every canonical pattern v2-v13 added. | |
| # | |
| # Discrete job (separate from cargo-test) so a regression in just one | |
| # widget surfaces as one red cell, not buried in the workspace test | |
| # output. cargo-test ALSO runs these tests as part of `cargo test | |
| # --workspace` — this job is the named visibility, not a duplicate run. | |
| # | |
| # Linker-side validation is covered by manual on-device runs against | |
| # DevEco Studio's Pura 90 Pro Max emulator (see CLAUDE.md v0.5.399+ | |
| # entries for the workflow). | |
| # --------------------------------------------------------------------------- | |
| harmonyos-smoke: | |
| # Aspirational smoke (informational) — must not block package publish. | |
| # release-packages await-tests keys on this workflow's run conclusion; | |
| # continue-on-error keeps a red result here from failing it (same as | |
| # parity/compile-smoke/doc-tests/drizzle/effect-basic-smoke). Core jobs | |
| # (cargo-test/lint/api-docs-drift/compiler-output-regression) still gate. | |
| continue-on-error: true | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Run perry-codegen-arkts unit tests | |
| run: cargo test -p perry-codegen-arkts --release --lib | |
| - name: Run Phase 2 full-app integration smoke | |
| run: | | |
| cargo test -p perry-codegen-arkts \ | |
| --release \ | |
| --test phase2_full_app_smoke \ | |
| -- --nocapture | |
| # --------------------------------------------------------------------------- | |
| # drizzle-mysql smoke: runs the tier-3 release fixture | |
| # `tests/release/packages/drizzle-mysql/` against a real MySQL — the CI | |
| # counterpart of #489's local acceptance, closing #804. | |
| # | |
| # The fixture itself stays Docker-free (per the tier-3 "no Docker" | |
| # preference in scripts/release_sweep_tiers/tier03_real_packages.sh — | |
| # locally it skips when no mysqld is reachable on 127.0.0.1:3306). The | |
| # `services: mysql:8` block below is runner-side setup, transparent to | |
| # the fixture. `MYSQL_ALLOW_EMPTY_PASSWORD` matches the fixture's | |
| # root-no-password convention; `MYSQL_DATABASE` auto-creates the test | |
| # DB on init so the fixture's CREATE-IF-NOT-EXISTS is a no-op. | |
| # | |
| # Gated to tag pushes + opt-in (parity with compile-smoke / doc-tests | |
| # / parity). Real-DB setup + perry release build + drizzle + | |
| # @perryts/mysql compile is ~15 min wall — PRs shouldn't pay it by | |
| # default. Opt-in via the `run-extended-tests` label. | |
| # --------------------------------------------------------------------------- | |
| drizzle-mysql-smoke: | |
| # Release-publish decoupling (see `parity` above): aspirational extended | |
| # suite, informational only — does not block package publishing. | |
| continue-on-error: true | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) || | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| runs-on: ubuntu-latest | |
| services: | |
| mysql: | |
| image: mysql:8 | |
| env: | |
| MYSQL_ALLOW_EMPTY_PASSWORD: "yes" | |
| MYSQL_DATABASE: perry_drizzle_test | |
| ports: | |
| - 3306:3306 | |
| options: >- | |
| --health-cmd="mysqladmin ping -h 127.0.0.1 -P 3306 --silent" | |
| --health-interval=5s | |
| --health-timeout=3s | |
| --health-retries=20 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| # Single source of truth: .node-version at the repo root. Node is the | |
| # gap/parity oracle (we byte-diff against it), so the version is a | |
| # correctness input, not an incidental toolchain detail — never pin it | |
| # inline here. See CLAUDE.md ("TypeScript Parity Status"). | |
| node-version-file: .node-version | |
| - name: Install mysql client (for fixture health probe) | |
| run: | | |
| sudo apt-get update -qq | |
| sudo apt-get install -y -qq mysql-client | |
| - name: Wait for MySQL service to accept connections | |
| run: | | |
| set -e | |
| for i in $(seq 1 30); do | |
| if mysql -h 127.0.0.1 -P 3306 -u root -e "SELECT 1" >/dev/null 2>&1; then | |
| echo "mysql ready after $i attempts" | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| mysql -h 127.0.0.1 -P 3306 -u root -e "SELECT VERSION();" | |
| - name: Build perry compiler | |
| run: cargo build --release -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p perry | |
| - name: Run drizzle-mysql fixture | |
| run: | | |
| cd tests/release/packages/drizzle-mysql | |
| PERRY_BIN="$GITHUB_WORKSPACE/target/release/perry" bash fixture.sh | |
| # --------------------------------------------------------------------------- | |
| # ink-link-smoke: runs the tier-3 release fixture | |
| # `tests/release/packages/ink-link-smoke/` as the CI counterpart to #803. | |
| # | |
| # This is intentionally compile/link-only. #348 tracks broader Ink runtime | |
| # and rendering compatibility; this job guards the package-graph + | |
| # compilePackages linker contract that the fixture documents. | |
| # | |
| # Gated to tag pushes + opt-in (parity with drizzle-mysql-smoke / | |
| # compile-smoke / doc-tests). The fixture installs Ink + React and builds a | |
| # release Perry compiler, so PRs shouldn't pay it by default. Opt-in via the | |
| # `run-extended-tests` label or workflow dispatch. | |
| # --------------------------------------------------------------------------- | |
| ink-link-smoke: | |
| # Aspirational smoke (informational) — see harmonyos-smoke. Does not block publish. | |
| continue-on-error: true | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) || | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| # Single source of truth: .node-version at the repo root. Node is the | |
| # gap/parity oracle (we byte-diff against it), so the version is a | |
| # correctness input, not an incidental toolchain detail — never pin it | |
| # inline here. See CLAUDE.md ("TypeScript Parity Status"). | |
| node-version-file: .node-version | |
| - name: Build perry compiler | |
| run: cargo build --release -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p perry | |
| - name: Run Ink link fixture | |
| run: | | |
| cd tests/release/packages/ink-link-smoke | |
| PERRY_BIN="$GITHUB_WORKSPACE/target/release/perry" bash fixture.sh | |
| - name: Upload Ink fixture logs | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: ink-link-smoke-logs | |
| path: | | |
| tests/release/packages/ink-link-smoke/install.log | |
| tests/release/packages/ink-link-smoke/perry-compile.log | |
| if-no-files-found: ignore | |
| # --------------------------------------------------------------------------- | |
| # effect-basic-smoke: runs the tier-3 release fixture | |
| # `tests/release/packages/effect-basic/` as the CI counterpart to #802. | |
| # | |
| # This is intentionally advisory: #802 asks for a live Effect compile/run | |
| # signal even while broader Effect end-to-end compatibility remains in | |
| # progress. Gated to tag pushes + opt-in, matching the named package smokes. | |
| # --------------------------------------------------------------------------- | |
| effect-basic-smoke: | |
| continue-on-error: true | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) || | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| # Single source of truth: .node-version at the repo root. Node is the | |
| # gap/parity oracle (we byte-diff against it), so the version is a | |
| # correctness input, not an incidental toolchain detail — never pin it | |
| # inline here. See CLAUDE.md ("TypeScript Parity Status"). | |
| node-version-file: .node-version | |
| - name: Build perry compiler | |
| run: cargo build --release -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p perry | |
| - name: Run Effect fixture | |
| run: | | |
| cd tests/release/packages/effect-basic | |
| PERRY_EFFECT_BASIC_ADVISORY=1 PERRY_BIN="$GITHUB_WORKSPACE/target/release/perry" bash fixture.sh | |
| - name: Upload Effect fixture logs | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: effect-basic-smoke-logs | |
| path: | | |
| tests/release/packages/effect-basic/install.log | |
| tests/release/packages/effect-basic/perry-compile.log | |
| tests/release/packages/effect-basic/perry-run.log | |
| tests/release/packages/effect-basic/perry-out.txt | |
| tests/release/packages/effect-basic/diff.log | |
| if-no-files-found: ignore | |
| # --------------------------------------------------------------------------- | |
| # Doc-example tests: compile + run every .ts under docs/examples/. | |
| # UI examples launch with PERRY_UI_TEST_MODE=1 so they auto-exit after one | |
| # frame. Gallery screenshots are diffed against per-OS baselines (advisory | |
| # until Linux/Windows baselines stabilize). | |
| # | |
| # Gated to tag pushes + opt-in (parity with parity/compile-smoke). The | |
| # macOS-14 matrix entry takes ~30 min wall and dominates the PR feedback | |
| # loop, so PRs no longer pay the bill by default. Release tags still run | |
| # doc-tests as part of the release-packages.yml gate. | |
| # | |
| # Opt-in: apply the `run-extended-tests` label to a PR, or dispatch the | |
| # workflow manually with `run_extended_tests=true`, to run this job on | |
| # demand. PR authors and maintainers can both apply labels. | |
| # --------------------------------------------------------------------------- | |
| doc-tests: | |
| # Blocking host runs and the portable cross-compile subset gate the job. | |
| # Known platform-tail failures remain advisory at their individual steps. | |
| if: >- | |
| github.event_name == 'push' || | |
| (github.event_name == 'workflow_dispatch' && inputs.run_extended_tests) || | |
| (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-extended-tests')) | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: macos-14 | |
| ui_backend: perry-ui-macos | |
| shell: bash | |
| # stdlib/http/snippets.ts excluded since v0.5.886: it links | |
| # against js_axios_response_data_parsed + | |
| # js_node_http2_create_secure_server which live in | |
| # perry-ext-axios / perry-ext-http. v0.5.885's | |
| # PERRY_NO_AUTO_OPTIMIZE skips the well-known-binding probe | |
| # that would route those .a files into the link surface. | |
| # Proper fix: hoist well-known-binding lookup out of | |
| # build_optimized_libs into link.rs so it runs even when | |
| # auto-optimize is skipped. Tracked separately. | |
| cmd_exclude_gallery: "./scripts/run_doc_tests.sh --verbose --skip-xcompile --filter-exclude ui/gallery.ts --filter-exclude stdlib/http/snippets.ts" | |
| cmd_gallery: "./scripts/run_doc_tests.sh --verbose --skip-xcompile --filter ui/gallery.ts" | |
| # Repeat `--xcompile-only-target=…` per target rather than a | |
| # single comma-delimited value because PowerShell splits even | |
| # `--foo=a,b` at the comma when unquoted (array literal). | |
| # Repetition sidesteps the whole issue on every shell. | |
| # | |
| # web + wasm cross-compile dropped from the macOS blocking gate | |
| # in v0.5.429 — both targets are portable and the ubuntu-24.04 | |
| # matrix entry below already verifies them at 1× billing | |
| # weight. Keep ios-simulator here because it requires Apple | |
| # SDK that only macos-14 has. | |
| cmd_xcompile_blocking: "./scripts/run_doc_tests.sh --verbose --xcompile-only --xcompile-only-target=ios-simulator" | |
| cmd_xcompile_advisory: "./scripts/run_doc_tests.sh --verbose --xcompile-only" | |
| # Baseline captured from a clean CI run of 24723671119 (900x970). | |
| gallery_advisory: false | |
| # ubuntu-24.04 / perry-ui-gtk4 doc-tests re-disabled v0.5.873: | |
| # 39 of 88 tests TIMEOUT at the 15s execution budget. The gtk4 | |
| # `glib::timeout_add_local_once` → `app.quit()` exit path | |
| # doesn't terminate the main loop cleanly under xvfb-run, so | |
| # PERRY_UI_TEST_MODE-driven self-exit never fires and the | |
| # harness has to SIGKILL each binary. Separate bug from the | |
| # webkit6 / soup / ed25519 fixes that v0.5.864→0.5.871 closed. | |
| # Tracked separately. Re-enable when the gtk4 testkit exit | |
| # path is fixed. | |
| # - os: ubuntu-24.04 | |
| # ui_backend: perry-ui-gtk4 | |
| # shell: bash | |
| # cmd_exclude_gallery: "xvfb-run -a ./scripts/run_doc_tests.sh --verbose --skip-xcompile --filter-exclude ui/gallery.ts" | |
| # cmd_gallery: "xvfb-run -a ./scripts/run_doc_tests.sh --verbose --skip-xcompile --filter ui/gallery.ts" | |
| # cmd_xcompile_blocking: "./scripts/run_doc_tests.sh --verbose --xcompile-only --xcompile-only-target=web --xcompile-only-target=wasm --xcompile-only-target=ios-simulator" | |
| # cmd_xcompile_advisory: "./scripts/run_doc_tests.sh --verbose --xcompile-only" | |
| # gallery_advisory: false | |
| # Re-enabled for #6624. The old 30+ COMPILE_FAIL cluster was mostly | |
| # one staticlib-boundary link gap: WinHTTP + SHCreateMemStream import | |
| # metadata did not reach Perry's final MSVC link. The compiler now | |
| # supplies winhttp.lib + shlwapi.lib explicitly, while later API | |
| # work closed the stale #463/WebView failures from that run. | |
| - os: windows-2022 | |
| ui_backend: perry-ui-windows | |
| shell: pwsh | |
| # The well-known HTTP aggregate remains excluded on every host | |
| # while its no-auto ext-archive routing issue is tracked. | |
| cmd_exclude_gallery: "./scripts/run_doc_tests.ps1 --verbose --skip-xcompile --filter-exclude ui/gallery.ts --filter-exclude stdlib/http/snippets.ts" | |
| cmd_gallery: "./scripts/run_doc_tests.ps1 --verbose --skip-xcompile --filter ui/gallery.ts" | |
| cmd_xcompile_blocking: "./scripts/run_doc_tests.ps1 --verbose --xcompile-only --xcompile-only-target=web --xcompile-only-target=wasm" | |
| cmd_xcompile_advisory: "./scripts/run_doc_tests.ps1 --verbose --xcompile-only" | |
| # Baseline captured from run #24735151417 (900x788). | |
| gallery_advisory: false | |
| runs-on: ${{ matrix.os }} | |
| defaults: | |
| run: | |
| shell: ${{ matrix.shell }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # macos-14 ships with ~14 GB free disk after the preinstalled Xcode + | |
| # iOS/tvOS/watchOS simulator runtime images. Several `cargo build | |
| # --release` jobs in this workflow consistently OOM'd at the cache | |
| # restore step (`No space left on device` from the runner's own | |
| # diagnostic writer, before cargo even started). Wiping the simulator | |
| # runtime IMAGES — not the SDKs — reclaims ~15-25 GB without | |
| # affecting cross-compile to `aarch64-apple-ios-sim` (that only needs | |
| # the SDK, which lives inside the active Xcode app). | |
| - name: Free up disk space (macOS) | |
| if: runner.os == 'macOS' | |
| run: | | |
| BEFORE=$(df -h / | tail -1 | awk '{print $4}') | |
| sudo rm -rf /Library/Developer/CoreSimulator/Profiles/Runtimes/*Simulator* || true | |
| sudo rm -rf ~/Library/Developer/CoreSimulator/Caches/* || true | |
| AFTER=$(df -h / | tail -1 | awk '{print $4}') | |
| echo "Disk free: ${BEFORE} -> ${AFTER}" | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - name: Set up MSVC environment (Windows) | |
| # Populates LIB / INCLUDE / PATH so link.exe can find user32.lib | |
| # etc. Without this, runner's MSVC install exists on disk but the | |
| # shell session has no clue where, and perry's LNK1181 fatal | |
| # errors looking for Windows SDK libs. | |
| if: matrix.os == 'windows-2022' | |
| uses: ilammy/msvc-dev-cmd@v1 | |
| - name: Install GTK4 + GStreamer + Xvfb + PulseAudio headers (Linux) | |
| if: matrix.os == 'ubuntu-24.04' | |
| run: | | |
| sudo apt-get update | |
| # libgstreamer1.0-dev + libgstreamer-plugins-base1.0-dev added in | |
| # v0.5.442 for PR #371 (perry/media streaming playback, #351). | |
| # gstreamer-sys's build script needs `gstreamer-1.0.pc` findable | |
| # via pkg-config; without these two packages doc-tests-gtk4 fails | |
| # at the cargo build step with "Package gstreamer-1.0 was not | |
| # found in the pkg-config search path". gstreamer-base is the | |
| # transitive dep gstreamer-base-sys needs for the playbin element | |
| # that perry/media wraps. | |
| # libwebkitgtk-6.0-dev added for the perry/ui-gtk4 WebView | |
| # feature (Phases 1-5 + v2 follow-ups, #658): the `webkit6` crate | |
| # (0.4 series) and its transitive `javascriptcore6-sys` need | |
| # `webkitgtk-6.0.pc` AND `javascriptcoregtk-6.0.pc` findable via | |
| # pkg-config. Ubuntu 24.04 (noble) ships libwebkitgtk-6.0-dev | |
| # which provides BOTH .pc files (the old libwebkit2gtk-4.1-dev | |
| # name only ships the 4.1 .pc and that's not what webkit6 wants). | |
| sudo apt-get install -y \ | |
| libgtk-4-dev libadwaita-1-dev xvfb pkg-config \ | |
| libpulse-dev \ | |
| libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \ | |
| libwebkitgtk-6.0-dev libshumate-dev | |
| - name: Surface Android NDK location (for cross-compile) | |
| if: matrix.os == 'macos-14' || matrix.os == 'ubuntu-24.04' | |
| run: | | |
| # 1. Discover NDK location on the runner. | |
| NDK="" | |
| if [ -n "$ANDROID_NDK_HOME" ]; then | |
| NDK="$ANDROID_NDK_HOME" | |
| elif [ -d "$ANDROID_HOME/ndk-bundle" ]; then | |
| NDK="$ANDROID_HOME/ndk-bundle" | |
| elif [ -d "$ANDROID_HOME/ndk" ]; then | |
| NDK=$(ls -1d "$ANDROID_HOME/ndk/"*/ 2>/dev/null | sort -V | tail -1) | |
| NDK="${NDK%/}" | |
| fi | |
| if [ -z "$NDK" ]; then | |
| echo "No Android NDK found on runner — android xcompile will skip" | |
| exit 0 | |
| fi | |
| echo "ANDROID_NDK_HOME=$NDK" >> "$GITHUB_ENV" | |
| # 2. Point cc-rs + cargo at the NDK's clang wrapper so | |
| # `cargo build --target aarch64-linux-android` picks up the | |
| # right linker/CC/AR instead of the host `cc`. | |
| HOST_TAG=$(uname -s | tr '[:upper:]' '[:lower:]')-x86_64 | |
| # macOS NDK uses darwin-x86_64 even on arm64 runners (rosetta). | |
| if [ "$(uname -s)" = "Darwin" ]; then HOST_TAG="darwin-x86_64"; fi | |
| TOOLCHAIN="$NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin" | |
| API=24 | |
| CLANG=$(ls "$TOOLCHAIN"/aarch64-linux-android*-clang 2>/dev/null | sort -V | tail -1) | |
| if [ -z "$CLANG" ]; then | |
| echo "Could not locate NDK clang under $TOOLCHAIN — skipping env setup" | |
| exit 0 | |
| fi | |
| echo "CC_aarch64_linux_android=$CLANG" >> "$GITHUB_ENV" | |
| echo "AR_aarch64_linux_android=$TOOLCHAIN/llvm-ar" >> "$GITHUB_ENV" | |
| echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$CLANG" >> "$GITHUB_ENV" | |
| echo "Android NDK wired: CLANG=$CLANG" | |
| - name: Install Apple SDK Rust targets (macOS only) | |
| if: matrix.os == 'macos-14' | |
| run: | | |
| rustup target add aarch64-apple-ios-sim | |
| # tvOS-sim is Rust Tier-3 — perry auto-rebuilds with | |
| # `+nightly -Zbuild-std`, which requires the rust-src | |
| # component on the nightly toolchain. | |
| rustup toolchain install nightly --component rust-src --profile minimal || true | |
| - name: Install Android Rust target (macOS + Ubuntu) | |
| if: matrix.os == 'macos-14' || matrix.os == 'ubuntu-24.04' | |
| run: rustup target add aarch64-linux-android | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Build compiler + UI backend + harness | |
| run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static -p ${{ matrix.ui_backend }} -p perry-doc-tests | |
| - name: Pre-build Apple UI libs for cross-compile (macOS only) | |
| if: matrix.os == 'macos-14' | |
| run: | | |
| # iOS-sim is Rust Tier-2 — builds with stable. tvOS-sim is Tier-3 | |
| # and needs nightly + -Zbuild-std; perry's auto-optimize handles | |
| # that path itself, so we skip pre-building perry-ui-tvos here. | |
| cargo build --release -p perry-ui-ios --target aarch64-apple-ios-sim | |
| - name: Lint docs/src markdown fences (repo-wide) | |
| if: matrix.os == 'macos-14' | |
| run: cargo run --release --quiet -p perry-doc-tests -- --lint docs/src | |
| - name: Run non-gallery doc-example tests (blocking) | |
| run: ${{ matrix.cmd_exclude_gallery }} | |
| - name: Run gallery screenshot diff | |
| id: gallery | |
| continue-on-error: ${{ matrix.gallery_advisory }} | |
| run: ${{ matrix.cmd_gallery }} | |
| - name: Cross-compile for web + wasm (blocking) | |
| id: xcompile_blocking | |
| run: ${{ matrix.cmd_xcompile_blocking }} | |
| - name: Cross-compile remaining targets (advisory) | |
| # iOS-sim/tvOS-sim/watchos-sim/android still surface real errors | |
| # that the harness logs but that aren't yet tracked issues. Keep | |
| # advisory until each target has a green baseline run. | |
| id: xcompile_advisory | |
| continue-on-error: true | |
| run: ${{ matrix.cmd_xcompile_advisory }} | |
| - name: Upload doc-tests report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: doc-tests-report-${{ matrix.os }} | |
| path: docs/examples/_reports/latest.json | |
| - name: Upload gallery screenshot + diff artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: gallery-screenshots-${{ matrix.os }} | |
| path: | | |
| target/perry-doc-tests/gallery_*.png | |
| docs/examples/_baselines/**/gallery.png | |
| # --------------------------------------------------------------------------- | |
| # Binary size tracking (main branch only) | |
| # --------------------------------------------------------------------------- | |
| binary-size: | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: macos-14 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@stable | |
| - uses: ./.github/actions/setup-llvm22 | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: "${{ runner.os }}-perry" | |
| save-if: ${{ github.ref == 'refs/heads/main' }} | |
| - name: Build release binaries | |
| run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static | |
| - name: Report binary sizes | |
| run: | | |
| echo "## Binary Sizes" > /tmp/sizes.md | |
| echo '```' >> /tmp/sizes.md | |
| ls -lh target/release/perry target/release/libperry_runtime.a target/release/libperry_stdlib.a 2>/dev/null | awk '{print $5, $9}' >> /tmp/sizes.md | |
| echo '```' >> /tmp/sizes.md | |
| cat /tmp/sizes.md | |
| - name: Upload size report | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: binary-sizes | |
| path: /tmp/sizes.md |