Skip to content

fix(codegen): a declared numeric type is not a proof that the value is a number #10471

fix(codegen): a declared numeric type is not a proof that the value is a number

fix(codegen): a declared numeric type is not a proof that the value is a number #10471

Workflow file for this run

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