Skip to content

fix: make compile output TypeScript-developer friendly #11637

fix: make compile output TypeScript-developer friendly

fix: make compile output TypeScript-developer friendly #11637

Workflow file for this run

name: CI
# ---------------------------------------------------------------------------
# ONE workflow, THREE tiers. Which jobs run is decided by `scripts/ci_plan.py`
# in the `plan` job below; every other job is `needs: plan` and gated on
# `fromJSON(needs.plan.outputs.plan).jobs.<name>`. Read that script's docstring
# for the policy and `python3 scripts/ci_plan.py --table` for the job x tier
# matrix. docs/src/testing/ci-tiers.md is the long-form page.
#
# pr pull_request the required gate. Small, fast, must be green
# on main. Fan-in job `pr-gate` is the ONLY
# required status context.
# sweep push: main coalesced post-merge sweep (at most one running
# + one pending, so a burst of merges is tested at
# its tip). PR tier unscoped + medium-weight jobs.
# full schedule / tags / everything, incl. parity, compile-smoke,
# workflow_dispatch doc-tests, package smokes, the 8-shard
# / label auto-optimize gap suite. `full-suite-gate` is
# what release-packages.yml waits for.
#
# WHY (2026-08-16): the org runs on 20 concurrent hosted runners (5 macOS).
# The previous shape fanned every PR push out to 14 workflows / 48 jobs /
# ~650 runner-minutes; at ~66 pushes/day that is 1.5-2x total capacity, so the
# queue never drained (job queue waits of 3-7 h), 0 of 66 PR runs of this
# workflow reached a conclusion in the sample window, and every merge was an
# admin bypass. Two required contexts (`parity`, `compile-smoke`) could not
# even report on a PR. See changelog.d/ for the measured numbers.
# ---------------------------------------------------------------------------
on:
pull_request:
branches: [main]
# `labeled` re-fires the run when a maintainer applies `run-extended-tests`
# (which promotes the PR to the `full` tier -- see ci_plan.py) or
# `skip-changelog`. NO `paths-ignore` here: a docs-only PR must still
# produce a `pr-gate` status (the plan turns everything but `lint` off for
# it), otherwise the required context never reports and the PR is stuck
# behind an admin bypass.
types: [opened, synchronize, reopened, labeled]
push:
branches: [main]
tags: ['v*']
schedule:
# Nightly full tier at 04:00 UTC — the daily arm for the slow suites
# (parity, compile-smoke, doc-tests, package smokes, the auto-optimize
# gap shards). Keep this string in lockstep with NIGHTLY_CRON in
# scripts/ci_plan.py: the planner maps THIS cron to `full` and any other
# cron to `sweep`.
- cron: '0 4 * * *'
# Two-hourly SWEEP BACKSTOP. The push-triggered sweep coalesces in one
# constant concurrency group (1 running + 1 pending, newest replaces
# pending) — and measured 2026-08-16..18, NO push sweep ever reached a
# runner: merges landed faster than the pending->running transition even
# with an idle queue, so `main-gate` had never once executed. This cron is
# the reliable arm; the push trigger stays for quiet periods. :47 to dodge
# the contended :00 scheduler slot and the six-hourly satellite gates.
- cron: '47 */2 * * *'
workflow_dispatch:
inputs:
tier:
description: 'Which tier to run (release-packages.yml dispatches `full`)'
type: choice
options: [pr, sweep, full]
default: full
update_gap_snapshot:
description: >-
Re-baseline test-parity/gap_snapshot.json: run the whole gap suite as
ONE shard with UPDATE_SNAPSHOT=1 and upload the resulting snapshot as
an artifact (`gap-snapshot-update`) for you to commit. Nothing else
about the run changes.
type: boolean
default: false
concurrency:
# pull_request: superseded pushes cancel their in-flight run (per PR).
# push to main: ONE constant group with cancel-in-progress OFF -- GitHub
# keeps at most one running + one pending run per group and replaces the
# pending one with the newest, so merges coalesce onto the tip instead of
# queueing 58 sweeps a day (#7205 / #7856 -- read
# docs/src/testing/ci-gate-scheduling.md before "fixing" this).
# everything else (schedule, tags, dispatch): keyed per RUN, because a group
# that is constant across scheduled runs lets only the first one execute
# (#7966). Enforced by scripts/gc_gate_wiring_check.py.
group: >-
${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number)
|| (github.event_name == 'push' && github.ref == 'refs/heads/main' && 'ci-main-sweep')
|| format('ci-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
MACOSX_DEPLOYMENT_TARGET: "13.0"
jobs:
# ---------------------------------------------------------------------------
# plan: the one job that decides what this run does. ~20 s, no toolchain.
# Everything below is `needs: plan` + `if: fromJSON(...).jobs.<x>`. If THIS
# job fails, every other job is skipped and the fan-in gate fails on
# `needs.plan.result != 'success'` -- a broken plan cannot silently pass.
# ---------------------------------------------------------------------------
plan:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
plan: ${{ steps.plan.outputs.plan }}
tier: ${{ steps.plan.outputs.tier }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Self-test the planner
run: python3 scripts/ci_plan.py --self-test
- name: List changed files (pull_request only)
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
# An API failure aborts here (set -e), which fails `plan` and with it
# the gate. It must NOT fall through to an empty list: ci_plan.py
# treats an empty listing as `core` for the same reason, but a
# loud failure is better than a silent full run.
gh api "repos/$REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename' > changed-files.txt
echo "Changed files ($(wc -l < changed-files.txt)):"
sed 's/^/ /' changed-files.txt
- name: Compute the plan
id: plan
env:
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
SCHEDULE: ${{ github.event.schedule }}
LABELS: ${{ github.event_name == 'pull_request' && join(github.event.pull_request.labels.*.name, ',') || '' }}
TIER_INPUT: ${{ inputs.tier }}
UPDATE_GAP_SNAPSHOT: ${{ inputs.update_gap_snapshot }}
run: |
set -euo pipefail
args=(--event "$EVENT_NAME" --ref "$REF" --labels "$LABELS")
if [ "$EVENT_NAME" = "pull_request" ]; then
args+=(--changed-files changed-files.txt)
fi
if [ "$EVENT_NAME" = "schedule" ]; then
args+=(--schedule "$SCHEDULE")
fi
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
args+=(--tier "${TIER_INPUT:-full}")
if [ "$UPDATE_GAP_SNAPSHOT" = "true" ]; then
args+=(--update-gap-snapshot)
fi
fi
python3 scripts/ci_plan.py "${args[@]}"
# ---------------------------------------------------------------------------
# Lint: cargo fmt --check + every no-compile audit script. Runs in EVERY
# tier, including docs-only PRs (it is the one job the plan never turns
# off). ~4 min. Add the ci_plan self-test here too so a policy edit that
# breaks its own invariants is red before it can plan anything.
# ---------------------------------------------------------------------------
lint:
needs: plan
if: fromJSON(needs.plan.outputs.plan).jobs.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
# Changeset gate (was its own job): 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). The `skip-changelog` label skips it (the
# `labeled` trigger refires the workflow). Lives in `lint` now because
# `pr-gate` is the single required context, so a red step here is as
# blocking as a red job was -- and one fewer job is one fewer runner
# slot on a 20-slot org.
- name: Require a changelog.d/ fragment for crates/ changes
if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'skip-changelog')
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate > files.json
# jq -e, no pipes: `lint` is a registered gate (gc_gate_wiring_check)
# and `jq | grep -q` under pipefail can fail on SIGPIPE when grep
# closes early on the first match.
# `-s` + `[.[][]]`: --paginate emits one JSON array per page, and
# `jq -e` reports only the LAST array's verdict without the slurp.
if ! jq -s -e '[.[][]] | any(.filename | startswith("crates/"))' files.json > /dev/null; then
echo "No crates/ changes — gate not applicable."; exit 0
fi
# 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.
if jq -s -e '[.[][]] | any(.status == "added" and (.filename | test("^changelog\\.d/[0-9]+-[^/]+\\.md$")))' files.json > /dev/null; then
exit 0
fi
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
- 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: Validate Windows LLVM runtime npm staging
run: ./tests/test_stage_npm_windows_llvm.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
# The CI tier policy (scripts/ci_plan.py) is code; its self-test asserts
# the invariants CLAUDE.md cares about (gc-stress main-line reachable,
# docs-only PRs skip the compile tier, an empty file listing is treated
# as core, ...). The table in docs/src/testing/ci-tiers.md is generated
# from it and must not drift.
- name: CI plan policy self-test + docs table freshness
if: ${{ !cancelled() }}
run: |
python3 scripts/ci_plan.py --self-test
python3 scripts/ci_plan.py --table > /tmp/ci-plan-table.md
if ! grep -qF -- "$(head -1 /tmp/ci-plan-table.md)" docs/src/testing/ci-tiers.md; then
echo "::error::docs/src/testing/ci-tiers.md is missing the tier table header"; exit 1
fi
python3 - <<'PY'
import re, sys
want = open('/tmp/ci-plan-table.md').read().strip()
doc = open('docs/src/testing/ci-tiers.md').read()
if want not in doc:
print("::error::docs/src/testing/ci-tiers.md tier table is stale. Regenerate with:")
print(" python3 scripts/ci_plan.py --table # and paste over the table")
sys.exit(1)
print("ci-tiers.md tier table matches ci_plan.py")
PY
- 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
# #7846: erased TypeScript annotations and initializer-refined local
# types are hints, not runtime proofs. Every codegen read must choose the
# whole-region write-invalidating accessor or carry an inventoried
# runtime/representation justification. Count-exact entries make both a
# new consumer and a stale exemption fail.
- name: Local binding type-proof audit
if: ${{ !cancelled() }}
run: |
python3 scripts/local_binding_type_audit.py --self-test
python3 scripts/local_binding_type_audit.py
# 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
# #8047/#8067. `ObjectHeader.object_type`, `field_count` and `keys_array`
# are being retired in favour of the authoritative ShapeId descriptor.
# #8086 migrated the guards AND built this exact-callsite census to keep
# them retired -- then wired it into nothing, so it has never been able to
# fail a build. A gate that no job invokes is documentation.
#
# The census is exact rather than a grep count: it strips comments and
# string literals, classifies each site declaration-vs-access, and diffs
# the full multiset against a reviewed baseline, so it can tell an
# ObjectHeader read from another struct's identically-named field. It
# also asserts the authority surfaces directly (no guard may compare a
# keys pointer, no emitter may bake header offsets 0/12/16) and carries
# its own lexer self-test plus sabotage self-tests, which run
# unconditionally on every invocation.
- name: Object-header shape-descriptor census
if: ${{ !cancelled() }}
run: python3 scripts/shape_descriptor_census.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
# #8174. A side table whose KEY is a raw heap address, REKEYED in place by
# `visit_metadata_*` (rewritten if the object moved, deliberately not
# marked), depends on `gc::dead_owner` dropping that key when the object
# dies. #8168 wired up the one table that had been missed; the invariant
# was otherwise maintained by a hand-written list happening to be
# complete, and a table added without a prune reappears days later as a
# `TypeError: value is not a function` in an unrelated function (#8040).
# This adjudicates every rekey site against the runtime registry: an
# unclassified site fails, and so does an exemption that matches nothing.
#
# Cheap and build-free, so it belongs in `lint`, which IS a required
# context (CLAUDE.md hazard 2).
- name: Rekeyed side-table custody audit
if: ${{ !cancelled() }}
run: |
python3 scripts/gc_rekeyed_key_tables.py --self-test
python3 scripts/gc_rekeyed_key_tables.py
# #7877. A deleted GC knob left executable CI arms that still looked
# distinct but selected the same collector. Derive the accepted names
# from live runtime/codegen parsers; historical journals are path-exact
# exemptions and cannot license a current script or reference page.
- name: GC environment-knob drift audit
if: ${{ !cancelled() }}
run: |
python3 scripts/check_gc_env_knobs.py --self-test
python3 scripts/check_gc_env_knobs.py
# #7982. The in-process LLVM reader's unit gate builds three tracked `.ll`
# corpora and asserts they RAN — which proves the tests ran, not that they
# test today's IR. All three froze on 2026-08-03 and carried zero
# `addrspace(1)` while RS4GC made it the shape of every GC root, so the
# unit gate stayed green through three `main` failures of the end-to-end
# arm. This asserts every IR form the reader carries a dedicated branch
# for is present in the corpora: a form that disappears fails here, and
# the fix is either a refresh (scripts/refresh_llvm_inprocess_corpora.sh)
# or deleting the now-untested branch.
- name: llvm-inprocess corpus currency
if: ${{ !cancelled() }}
run: |
python3 scripts/check_llvm_corpus_currency.py --self-test
python3 scripts/check_llvm_corpus_currency.py
# #7877, second round. The knob audit above covers env-var names; it says
# nothing about the paths and numbers the same pages state. Both drifted:
# the memory-model source map pointed at a `gc.rs` deleted in the module
# split (with line numbers), and CLAUDE.md carried "~55 registered
# scanners" against a population of 123 in two places, of which only one
# got corrected by hand. Numbers on the current pages now carry `gc-fact`
# markers naming the constant they came from, and this re-derives them.
# Build-free, so it belongs in `lint`, which IS a required context.
- name: GC documentation claim audit
if: ${{ !cancelled() }}
run: |
python3 scripts/check_gc_doc_claims.py --self-test
python3 scripts/check_gc_doc_claims.py
# #7977. The audits above ALSO run as `windows-build`'s first step, where
# a bare `read_text()` decodes cp1252 and dies on the 15 runtime sources
# carrying 0x81/0x8d/0x8f/0x90/0x9d. That took the whole Windows job down
# — including the only Windows run of the perry-runtime unit tests —
# before any of it executed. #7882 fixed three of the four readers; this
# is what stops the fourth miss from being found on Windows again.
#
# It runs HERE, on Linux, in a REQUIRED context, precisely because the
# defect is invisible on Linux at runtime: the scan is static, so the
# class is caught per-PR rather than only when a Windows runner gets to
# it. `--self-test` plants each shape, so the checker can say no.
- name: Windows-portable text I/O in the Windows-CI audits
if: ${{ !cancelled() }}
run: |
python3 scripts/check_locale_independent_io.py --self-test
python3 scripts/check_locale_independent_io.py
# #7970. `gc_evacuation_liveness_assert.py` is the liveness gate for every
# forced-evacuation arm, and it reads ONLY `[gc-copy-minor]` lines, which
# exist only under `PERRY_GC_DIAG=1`. When an arm forgot that flag the
# assert reported "the forced-evacuation arm evacuated NOTHING" — sending
# readers to debug a collector that was in fact evacuating 16277 objects
# per run. It now separates "the instrument was off" from "the subject was
# dead", and this proves it still can, in both directions. Hermetic and
# instant, so it belongs in `lint` (a REQUIRED context) rather than only
# in the GC gate it serves — which is itself still red.
- name: GC evacuation-liveness assert can say no
if: ${{ !cancelled() }}
run: python3 scripts/gc_evacuation_liveness_assert.py --self-test
# Node is a correctness input (see CLAUDE.md "TypeScript Parity Status"):
# an oracle that cannot run a gap test drops it from the gate instead of
# failing it. #6367 made `.node-version` the single pin, and the pin has
# leaked twice since -- CLAUDE.md's prose drifted off the file (#7599), and
# npm-launcher.yml was created the same day as #6367 and kept that day's
# ambient "22.23.1" literal by omission. This re-derives every restatement
# from the file it quotes and requires every literal `node-version:` in a
# workflow to be a registered exemption with a reason. Build-free, so it
# belongs in `lint`, which IS a required context.
- name: Node version consistency
if: ${{ !cancelled() }}
run: |
python3 scripts/check_node_version_consistency.py --self-test
python3 scripts/check_node_version_consistency.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
# #8233. The debt ratchet above counts bare reads OUT OF a RuntimeHandle --
# debt in code that ALREADY adopted the rooting API. Code that never roots
# at all has no `get_raw_*_ptr` to count and scores ZERO, the ratchet's best
# possible result, and its scope is perry-runtime only. This one detects the
# SHAPE instead -- a local bound from an allocator return, used after an
# intervening call that can allocate or run JS -- over perry-stdlib and
# perry-ext-*, which sit outside that denominator entirely. Ratchet, not a
# zero target: it fails only on an INCREASE over
# scripts/unrooted_local_shape_baseline.json.
- name: Unrooted-local shape ratchet
if: ${{ !cancelled() }}
run: |
python3 scripts/unrooted_local_shape.py --self-test
python3 scripts/unrooted_local_shape.py --check
# The checked-out baseline above is controlled by the same diff it
# measures. Compare its recorded ceilings with the merge base too, so a
# PR cannot add findings and raise the baseline to hide them.
- name: Unrooted-local shape 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/unrooted_local_shape.py --no-raise-vs "$BASE_SHA"
# #7659: the raw-handle check 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: |
./scripts/gc_repsel_matrix.sh --self-test-liveness-parser
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
# ---------------------------------------------------------------------------
# check: clippy (both scopes) + API-docs drift, in ONE job. Was three jobs
# (Clippy x2 matrix, api-docs-drift); merged because each job is a runner
# slot on a 20-slot org and all three share the same toolchain, cache and
# `cargo check` metadata. Steps are `if: !cancelled()` so a clippy failure
# still reports the docs verdict.
#
# 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.
# ---------------------------------------------------------------------------
check:
needs: plan
if: fromJSON(needs.plan.outputs.plan).jobs.check
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: ./.github/actions/setup-llvm22
- uses: Swatinem/rust-cache@v2
with:
shared-key: "${{ runner.os }}-perry"
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Clippy (product)
if: ${{ !cancelled() }}
run: cargo clippy -p perry --bins
- name: Clippy (host-compatible)
if: ${{ !cancelled() }}
run: |
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[@]}"
- name: Regenerate API docs
if: ${{ !cancelled() }}
run: ./scripts/regen_api_docs.sh
- name: Check for API docs drift
if: ${{ !cancelled() }}
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."
# ---------------------------------------------------------------------------
# rustc warnings gate
#
# `cargo check` with `-D warnings`, so a PR cannot add a rustc warning. This
# is deliberately separate from the `check` job above: clippy's own
# warn-level lints are informational there, while rustc's are not -- and
# `-D warnings` on the clippy invocation would promote every clippy warning
# too. Both scopes run in ONE job (was a 2-way matrix): they share the
# toolchain and cache, and each job is a runner slot.
#
# 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.
# ---------------------------------------------------------------------------
warnings:
needs: plan
if: fromJSON(needs.plan.outputs.plan).jobs.warnings
runs-on: ubuntu-latest
timeout-minutes: 60
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.11
- name: Restore sccache objects
uses: actions/cache/restore@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: rustc warnings (product)
if: ${{ !cancelled() }}
run: cargo check -p perry --bins
- name: rustc warnings (host-compatible, all targets)
if: ${{ !cancelled() }}
run: |
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[@]}"
# sccache SAVE is main-line only (restore above is unconditional). PR
# runs used to write a fresh ~0.5-1.3 GB tarball per job per push --
# ~200 GB/day into a 10 GB repo cache budget -- which evicted every
# useful entry (including rust-cache's) within the hour. Now only sweep /
# nightly / release runs write, and PRs restore the newest main-line blob
# via the restore-keys prefix, i.e. a cache built from the tip they
# branched from.
- name: Save sccache objects (main-line runs only)
if: always() && github.event_name != 'pull_request'
uses: actions/cache/save@v6
with:
path: ${{ github.workspace }}/.sccache
key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }}
# ---------------------------------------------------------------------------
# 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:
needs: plan
if: fromJSON(needs.plan.outputs.plan).jobs.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.11
# 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: Restore sccache objects
uses: actions/cache/restore@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 [ "${{ fromJSON(needs.plan.outputs.plan).cargo_test_scope }}" = "pr" ]; 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 [ "${{ fromJSON(needs.plan.outputs.plan).cargo_test_scope }}" != "pr" ]; 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
# #8113 — the perry-ffi <-> perry-runtime ABI mirror, which had NEVER
# EXECUTED. `perry_ffi::types::layout_tests` is
# `#[cfg(all(test, feature = "runtime-link"))]`, `runtime-link` was
# enabled nowhere in `.github/`, and the scope loop above runs
# `cargo test -p perry-ffi` with DEFAULT features — so the module never
# even compiled. Deleting a mirrored field still went red (an `offset_of!`
# on a missing field stops compiling), but a SIZE or PADDING divergence
# between the two structs was invisible, which is precisely the failure
# mode of a header-layout change.
#
# Unconditional, not scope-gated: perry-ffi's optional dependency on
# perry-runtime means a runtime-only diff need not pull perry-ffi into
# scope, and this is the one check that says the published ABI mirror
# still matches the runtime it mirrors.
- name: perry-ffi ABI mirror matches the runtime (#8113)
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld"
CARGO_PROFILE_TEST_DEBUG: "0"
run: cargo test -p perry-ffi --features runtime-link --lib
# sccache SAVE is main-line only (restore above is unconditional). PR
# runs used to write a fresh ~0.5-1.3 GB tarball per job per push --
# ~200 GB/day into a 10 GB repo cache budget -- which evicted every
# useful entry (including rust-cache's) within the hour. Now only sweep /
# nightly / release runs write, and PRs restore the newest main-line blob
# via the restore-keys prefix, i.e. a cache built from the tip they
# branched from.
- name: Save sccache objects (main-line runs only)
if: always() && github.event_name != 'pull_request'
uses: actions/cache/save@v6
with:
path: ${{ github.workspace }}/.sccache
key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }}
# ---------------------------------------------------------------------------
# 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:
needs: plan
if: fromJSON(needs.plan.outputs.plan).jobs.e2e_scoped
# PR-only: tags / nightly / workflow_dispatch already run every suite in the
# full `cargo-test` path.
runs-on: ubuntu-latest
# GitHub-hosted runners enforce a six-hour job limit. The per-suite bounds
# below isolate a hung compile and provide a useful named failure; they are
# not an additive job budget. The mapped in-process suites measure 2.2-10.4
# seconds each, and the common case selects no suite and exits in ~20-30s.
timeout-minutes: 360
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. Rust setup below
# is skipped when the diff names no suite and there are no exclusions to
# validate. The whole job is absent on docs-only PRs via ci_plan.py.
- 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/#8266: selected suites skip held-out tests, while a separate
# scope-independent step below asserts every held-out test still
# fails on every core PR.
exclusions="$(python3 scripts/ci_e2e_scope.py --exclusions)"
{
echo 'exclusions<<PERRY_EOF'
printf '%s\n' "$exclusions"
echo 'PERRY_EOF'
} >> "$GITHUB_OUTPUT"
if [ -n "$suites" ] || [ -n "$exclusions" ]; then
echo "rust_work=true" >> "$GITHUB_OUTPUT"
else
echo "rust_work=false" >> "$GITHUB_OUTPUT"
fi
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.rust_work == 'true'
uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-llvm22
if: steps.scope.outputs.rust_work == 'true'
- name: Install sccache
if: steps.scope.outputs.rust_work == 'true'
uses: mozilla-actions/sccache-action@v0.0.11
- name: Restore sccache objects
if: steps.scope.outputs.rust_work == 'true'
uses: actions/cache/restore@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.rust_work == 'true'
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.rust_work == 'true'
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"
exit "$status"
# #7708/#8266: exclusions are self-invalidating independently of the
# selected suite set. A held-out test that now PASSES (or no longer
# exists under that name) fails every core PR, including a fix in HIR,
# transform, or another dependency that selects no codegen suite.
- name: Validate known-failure exclusions
if: steps.scope.outputs.exclusions != ''
env:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld"
CARGO_PROFILE_TEST_DEBUG: "0"
CARGO_PROFILE_DEV_DEBUG: "0"
CARGO_BUILD_JOBS: "1"
EXCLUSIONS: ${{ steps.scope.outputs.exclusions }}
run: |
status=0
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"
exit "$status"
# sccache SAVE is main-line only (restore above is unconditional). PR
# runs used to write a fresh ~0.5-1.3 GB tarball per job per push --
# ~200 GB/day into a 10 GB repo cache budget -- which evicted every
# useful entry (including rust-cache's) within the hour. Now only sweep /
# nightly / release runs write, and PRs restore the newest main-line blob
# via the restore-keys prefix, i.e. a cache built from the tip they
# branched from.
- name: Save sccache objects (main-line runs only)
if: always() && github.event_name != 'pull_request'
uses: actions/cache/save@v6
with:
path: ${{ github.workspace }}/.sccache
key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }}
# ---------------------------------------------------------------------------
# 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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.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: GC structural audits (Windows)
shell: bash
env:
# #7977 belt-and-braces. These scripts read Rust sources that contain
# bytes cp1252 has no mapping for; the ACTUAL fix is an explicit
# `encoding="utf-8"` at every call site, enforced on Linux by
# `scripts/check_locale_independent_io.py` in `lint`. This makes a
# future miss in a script that gate does not yet cover degrade to
# "works anyway" instead of taking the whole job — and with it the
# only Windows run of the perry-runtime unit tests — down at step one.
PYTHONUTF8: "1"
run: |
set -euo pipefail
python scripts/gc_runtime_root_holders.py --self-test
python scripts/gc_runtime_root_holders.py
python scripts/check_gc_doc_claims.py --self-test
python scripts/check_gc_doc_claims.py
python scripts/check_thread_locals.py --self-test
python scripts/check_thread_locals.py
python -m unittest discover -s tests -p 'test_gc_ratchet.py' -v
python benchmarks/gc_ratchet/gc_ratchet.py validate --scope structural
- 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
}
# Native Windows ARM64 coverage for #4482. The x64 Windows gate above cannot
# execute an ARM binary, so keep a focused native job that proves the
# compiler, runtime ABI, final linker, and advertised target all agree.
windows-arm64-build:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.windows_arm64_build
runs-on: windows-11-arm
timeout-minutes: 75
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- uses: ./.github/actions/setup-llvm22
- name: Set up ARM64 MSVC environment
# The native ARM64 runner has the Windows SDK installed, but its
# PowerShell environment does not include the SDK's ARM64 library
# directories. Populate LIB / INCLUDE / PATH so the final PE link can
# resolve system import libraries such as user32.lib.
uses: ilammy/msvc-dev-cmd@v1
with:
arch: amd64_arm64
- uses: Swatinem/rust-cache@v2
with:
shared-key: "${{ runner.os }}-${{ runner.arch }}-perry"
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build ARM64 compiler and static libraries (perry-dev)
run: cargo build --profile perry-dev -p perry -p perry-runtime-static -p perry-stdlib-static
- name: Run ARM64 setjmp ABI tests
run: 'cargo test --profile perry-dev --lib -p perry-runtime ffi::setjmp::tests:: -- --test-threads=1'
- name: Compile and run a Perry ARM64 executable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Keep this a runtime-only smoke. The full runtime+stdlib COFF archive
# composition has its own gate; this job's contract is the ARM ABI,
# target selection, SDK discovery, final PE link, and execution.
New-Item -ItemType Directory -Force 'target/windows-arm64-smoke-libs' | Out-Null
Copy-Item 'target/perry-dev/perry_runtime.lib' 'target/windows-arm64-smoke-libs/'
$env:PERRY_RUNTIME_DIR = (Resolve-Path 'target/windows-arm64-smoke-libs').Path
target/perry-dev/perry.exe compile tests/release/link_smoke/fixture.ts `
--target windows-aarch64 `
--no-auto-optimize `
-o target/windows-arm64-smoke.exe
# In expression position PowerShell treats a relative path as a
# command name and does not search the working directory. Invoke the
# freshly linked binary explicitly so the ARM64 gate tests the
# artifact instead of failing in shell dispatch.
$smoke = (Resolve-Path 'target/windows-arm64-smoke.exe').Path
$output = & $smoke
if ($output.Trim() -ne 'ok') {
throw "unexpected ARM64 smoke output: '$output'"
}
# ---------------------------------------------------------------------------
# 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:
needs: plan
if: fromJSON(needs.plan.outputs.plan).jobs.gc_stress
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: fromJSON(needs.plan.outputs.plan).gc_stress_mode == 'pr'
run: ./scripts/gc_repsel_matrix.sh --no-build --arms pr --json gc-repsel-matrix.json
- name: GC x representation-selection matrix (full)
if: fromJSON(needs.plan.outputs.plan).gc_stress_mode == 'full'
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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.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.11
# 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: Restore sccache objects
uses: actions/cache/restore@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/
# sccache SAVE is main-line only (restore above is unconditional). PR
# runs used to write a fresh ~0.5-1.3 GB tarball per job per push --
# ~200 GB/day into a 10 GB repo cache budget -- which evicted every
# useful entry (including rust-cache's) within the hour. Now only sweep /
# nightly / release runs write, and PRs restore the newest main-line blob
# via the restore-keys prefix, i.e. a cache built from the tip they
# branched from.
- name: Save sccache objects (main-line runs only)
if: always() && github.event_name != 'pull_request'
uses: actions/cache/save@v6
with:
path: ${{ github.workspace }}/.sccache
key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }}
# ---------------------------------------------------------------------------
# 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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.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.11
- name: Restore sccache objects
uses: actions/cache/restore@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/
# sccache SAVE is main-line only (restore above is unconditional). PR
# runs used to write a fresh ~0.5-1.3 GB tarball per job per push --
# ~200 GB/day into a 10 GB repo cache budget -- which evicted every
# useful entry (including rust-cache's) within the hour. Now only sweep /
# nightly / release runs write, and PRs restore the newest main-line blob
# via the restore-keys prefix, i.e. a cache built from the tip they
# branched from.
- name: Save sccache objects (main-line runs only)
if: always() && github.event_name != 'pull_request'
uses: actions/cache/save@v6
with:
path: ${{ github.workspace }}/.sccache
key: sccache-${{ runner.os }}-perry-${{ github.job }}-${{ github.run_id }}
# ---------------------------------------------------------------------------
# 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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.native_abi_evidence_packet
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.11
- name: Restore sccache objects
uses: actions/cache/restore@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/
# sccache SAVE is main-line only (restore above is unconditional). PR
# runs used to write a fresh ~0.5-1.3 GB tarball per job per push --
# ~200 GB/day into a 10 GB repo cache budget -- which evicted every
# useful entry (including rust-cache's) within the hour. Now only sweep /
# nightly / release runs write, and PRs restore the newest main-line blob
# via the restore-keys prefix, i.e. a cache built from the tip they
# branched from.
- name: Save sccache objects (main-line runs only)
if: always() && github.event_name != 'pull_request'
uses: actions/cache/save@v6
with:
path: ${{ github.workspace }}/.sccache
key: sccache-${{ runner.os }}-perry-native-abi-evidence-${{ github.run_id }}
# ---------------------------------------------------------------------------
# gap-suite (was `conformance-smoke`): the gap suite, sharded. Runs in every
# tier -- 6 fast-mode shards on a PR, 3 in the sweep, 8 auto-optimize shards
# in the full tier (scripts/ci_plan.py GAP_SUITE). The `gate` fan-in below is
# what branch protection requires; a single shard's red bubbles up through it.
# ---------------------------------------------------------------------------
gap-suite:
needs: plan
if: fromJSON(needs.plan.outputs.plan).jobs.gap_suite
# 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 (--shard N/M, M from the plan). Each shard's snapshot gate
# (run_gap_tests.sh) covers only its own slice, which is exactly the right
# per-shard semantics; the `gate` fan-in job is the one status branch
# protection requires. 2026-08-16: in the harness's default (auto-optimize)
# mode 96% of a shard's wall time was ~10 tests at ~200 s each -- the
# feature-stripped runtime rebuild per distinct feature set, redone in
# every shard. That mode is now the full tier's 8-shard arm; PR and sweep
# tiers use `fast` mode against one prebuilt release build (~1.5 s/test).
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(needs.plan.outputs.plan).gap.shards }}
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 the fan-in was 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
# #8198: six gap tests import npm packages that are now root
# devDependencies (`.npmrc`: the root install "materializes the
# parity-test fixture deps"). Without this the oracle cannot resolve
# them, node exits 1, and the tests read as parity failures.
- name: Install the npm packages the gap tests' oracle imports
run: npm ci --ignore-scripts --no-audit --no-fund
# Two harness modes, chosen by the plan (see ci_plan.py GAP_SUITE):
# fast: build the release compiler + runtime archives ONCE here, then
# PERRY_SKIP_BUILD=1 so every test links the prebuilt archives
# (~1.5 s/test). Only ext-routed tests (http/net/ws/zlib/events)
# still take the per-test auto-optimize path, because no single
# prebuilt stdlib can serve them (#7629). PR + sweep tiers.
# full: the harness's default -- every test compiles through
# auto-optimize, which rebuilds a feature-stripped runtime per
# distinct feature set (~200 s each; measured 96% of a shard's
# wall time). It is the arm that sees auto-optimize-only bugs,
# so it stays in the nightly/release tier at 8 shards.
# Both compare against the SAME committed Linux snapshot; a divergence
# between them is a real auto-optimize-specific finding, not noise.
- name: Build compiler + runtime archives (fast mode)
if: fromJSON(needs.plan.outputs.plan).gap.mode == 'fast'
# Release profile, codegen-units=1: the parity harness's own build
# command. cu=16 miscompiles the release runtime (see CLAUDE.md).
run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static
- name: Run gap suite (shard ${{ matrix.shard }}/${{ fromJSON(needs.plan.outputs.plan).gap.total }}, ${{ fromJSON(needs.plan.outputs.plan).gap.mode }} mode)
env:
GAP_MODE: ${{ fromJSON(needs.plan.outputs.plan).gap.mode }}
GAP_TOTAL: ${{ fromJSON(needs.plan.outputs.plan).gap.total }}
GAP_SHARD: ${{ matrix.shard }}
UPDATE_SNAPSHOT: ${{ fromJSON(needs.plan.outputs.plan).gap.update_snapshot && '1' || '0' }}
run: |
set -euo pipefail
if [ "$GAP_MODE" = "fast" ]; then
export PERRY_SKIP_BUILD=1
export PERRY_BIN="$PWD/target/release/perry"
export PERRY_RUNTIME_DIR="$PWD/target/release"
fi
if [ "$GAP_TOTAL" = "1" ]; then
./scripts/run_gap_tests.sh
else
./scripts/run_gap_tests.sh --shard "$GAP_SHARD/$GAP_TOTAL"
fi
# Only produced by a `workflow_dispatch` with update_gap_snapshot=true
# (one shard, whole suite). Download it and commit test-parity/
# gap_snapshot.json -- see docs/src/testing/ci-tiers.md "Re-baselining".
- name: Upload re-baselined gap snapshot
if: fromJSON(needs.plan.outputs.plan).gap.update_snapshot
uses: actions/upload-artifact@v7
with:
name: gap-snapshot-update
path: test-parity/gap_snapshot.json
- name: Upload gap report
if: always()
uses: actions/upload-artifact@v7
with:
name: gap-suite-report-${{ fromJSON(needs.plan.outputs.plan).gap.mode }}-shard-${{ matrix.shard }}
path: test-parity/reports/
# ---------------------------------------------------------------------------
# Parity tests (Perry output vs Node.js)
# ---------------------------------------------------------------------------
# parity: the full Perry-vs-node parity sweep, SHARDED. Full tier only.
#
# Sharded 2026-08-16: the unsharded job was killed by GitHub's 6-hour job
# cap (run 31935729773, 11:44 -> 17:45) — the release gate could not
# complete even in principle. Each shard runs `run_parity_tests.sh
# --shard N/M` (round-robin partition, same mechanism as gap-suite) plus
# `parity_known_failures.py`, which is shard-safe by design ("not in this
# shard is never flagged"). The AGGREGATE gates — the threshold minimums
# and the per-module matrix trend, whose baselines describe the whole
# suite — run once in `parity-aggregate` below over the merged report
# (scripts/parity_report_merge.py, which FAILS on a missing shard rather
# than shrinking the suite).
#
# No `continue-on-error`: since the tiered restructure a red here fails
# `full-suite-gate`, which is exactly what release-packages.yml's
# await-tests keys on. If a standing failure must not block a release,
# triage it into test-parity/known_failures.json — do not soften the job.
# ---------------------------------------------------------------------------
parity:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.parity
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(needs.plan.outputs.plan).parity.shards }}
runs-on: ubuntu-latest
timeout-minutes: 150
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
# #8198: the npm-fixture parity/gap tests import root devDependencies.
- name: Install the npm packages the oracle imports
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Build compiler
run: cargo build --release
- name: Run parity tests (shard ${{ matrix.shard }}/${{ fromJSON(needs.plan.outputs.plan).parity.total }})
run: ./run_parity_tests.sh --shard ${{ matrix.shard }}/${{ fromJSON(needs.plan.outputs.plan).parity.total }}
# 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. Shard-safe: an entry whose test is not in this shard is never
# flagged, so each shard adjudicates exactly its own slice.
- 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: Upload shard report
if: always()
uses: actions/upload-artifact@v7
with:
name: parity-shard-${{ matrix.shard }}
path: |
test-parity/reports/latest.json
test-parity/output/node/test_parity_*.txt
test-parity/output/perry/test_parity_*.txt
if-no-files-found: error
# 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 }}-shard-${{ matrix.shard }}
path: test-parity/output/*.compile_error.log
if-no-files-found: ignore
# ---------------------------------------------------------------------------
# parity-aggregate: fan-in for the sharded parity sweep. Merges the shard
# reports into one whole-suite report and runs the gates whose baselines
# only make sense on the aggregate: the global + per-category threshold
# minimums (a 62%-floor category with two tests in a shard would flap) and
# the per-module matrix trend. The merge REFUSES a missing shard (--expect),
# so a lost artifact is a red run, not a smaller green suite.
# ---------------------------------------------------------------------------
parity-aggregate:
needs: [plan, parity]
# `!cancelled()`: run even when a shard FAILED its verdict (dark debt in
# the known-failures gate) — the merged report is exactly what a triage
# needs, and the threshold/trend verdicts are meaningful regardless. A
# missing shard ARTIFACT still fails the merge (--expect). Skipped only
# when the plan turned parity off.
if: ${{ !cancelled() && fromJSON(needs.plan.outputs.plan).jobs.parity }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Download shard reports
uses: actions/download-artifact@v8
with:
pattern: parity-shard-*
path: parity-shards
- name: Merge shard reports
env:
EXPECT: ${{ fromJSON(needs.plan.outputs.plan).parity.total }}
run: |
set -euo pipefail
python3 scripts/parity_report_merge.py --self-test
mkdir -p test-parity/reports test-parity/output/node test-parity/output/perry
# Reconstruct the per-test output captures the matrix trend reads.
# NOTE: upload-artifact strips the common parent, so each artifact
# extracts as parity-shard-N/{reports,output}/... (no test-parity/
# prefix) — verified against run 31964093732's artifacts.
for d in parity-shards/parity-shard-*/; do
if [ -d "$d/output/node" ]; then cp "$d"/output/node/*.txt test-parity/output/node/ 2>/dev/null || true; fi
if [ -d "$d/output/perry" ]; then cp "$d"/output/perry/*.txt test-parity/output/perry/ 2>/dev/null || true; fi
done
python3 scripts/parity_report_merge.py \
--expect "$EXPECT" \
--output test-parity/reports/latest.json \
parity-shards/parity-shard-*/reports/latest.json
- 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"
- 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 merged 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
# ---------------------------------------------------------------------------
# Compile smoke test (all 130+ test files must compile)
# ---------------------------------------------------------------------------
compile-smoke:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.compile_smoke
# Release-publish decoupling (see `parity` above): aspirational extended
# suite, informational only — does not block package publishing.
# 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.
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
# #8224: `--release` with no `-p` builds default-members only and
# NEVER emits libperry_{runtime,stdlib}.a (rlib-only since #5422), so
# the precompile fixtures that link the archives directly could not
# pass — masked for weeks by continue-on-error. Name the wrapper
# crates explicitly (same set the parity harness builds).
run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static
- 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_ui_drag_drop \
test_ui_text_alignment \
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_jwt_sign_dynamic_alg \
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 the default,
# full mark-sweep, explicit generational, and forced-evacuation verifier
# configurations. Linux/macOS 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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.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.
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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.drizzle_mysql_smoke
# Release-publish decoupling (see `parity` above): aspirational extended
# suite, informational only — does not block package publishing.
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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.ink_link_smoke
# Aspirational smoke (informational) — see harmonyos-smoke. Does not block publish.
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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.effect_basic_smoke
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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.doc_tests
# Blocking host runs and the portable cross-compile subset gate the job.
# Known platform-tail failures remain advisory at their individual steps.
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:
# Sweep/full-tier only. Chained behind `check` so a sweep's fan-out does
# not take every runner slot the moment a merge lands -- PR gates share
# the same 20-slot pool.
needs: [plan, check]
if: fromJSON(needs.plan.outputs.plan).jobs.binary_size
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
# ---------------------------------------------------------------------------
# security-audit: cargo-audit / cargo-deny / supply-chain soak gate / agent +
# skills scans, from the reusable .github/workflows/security-audit.yml. In
# the PR tier the plan turns it on only when the diff touches a lockfile,
# manifest or policy file (ci_plan.py DEPS_GLOBS) -- a code-only PR cannot
# introduce a new advisory, and the weekly schedule in that workflow catches
# advisory-database updates on their own. Sweep and full tiers always run it.
# ---------------------------------------------------------------------------
security-audit:
needs: plan
if: fromJSON(needs.plan.outputs.plan).jobs.security_audit
uses: ./.github/workflows/security-audit.yml
permissions:
contents: read
secrets: inherit
# ---------------------------------------------------------------------------
# THE fan-in. One job, one status, named by tier so the outside world can
# key on it without knowing the job list:
#
# pr-gate pull_request runs. THE ONLY REQUIRED STATUS CONTEXT.
# Adding or removing a job above never needs a
# branch-protection edit again.
# main-gate push-to-main sweeps.
# full-suite-gate full-tier runs (nightly, tags, dispatch, labelled PRs
# -- a labelled PR is still `pr-gate`, the event wins).
# release-packages.yml's await-tests polls for THIS job
# on the release SHA, so a sweep-level green can never
# be mistaken for a release-grade one.
#
# `if: always()` so it reports even when something upstream failed or was
# cancelled -- a fan-in that is skipped when a dependency fails would leave
# the required context in "expected" limbo, which is exactly the state that
# forces admin bypasses. Verdict rules:
# * `plan` itself must be `success` (a broken planner must not turn into
# "everything skipped, therefore green");
# * every other needed job must be `success` or `skipped` (skipped == the
# plan turned it off, which is fine); `failure` and `cancelled` fail.
# ---------------------------------------------------------------------------
gate:
name: ${{ github.event_name == 'pull_request' && 'pr-gate' || (needs.plan.outputs.tier == 'full' && 'full-suite-gate' || 'main-gate') }}
if: always()
needs:
- plan
- lint
- check
- warnings
- cargo-test
- e2e-scoped
- windows-build
- windows-arm64-build
- gc-stress
- compiler-output-regression
- repsel-census
- native-abi-evidence-packet
- gap-suite
- parity
- parity-aggregate
- compile-smoke
- harmonyos-smoke
- drizzle-mysql-smoke
- ink-link-smoke
- effect-basic-smoke
- doc-tests
- binary-size
- security-audit
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require the plan and every planned job to have passed
env:
NEEDS: ${{ toJSON(needs) }}
TIER: ${{ needs.plan.outputs.tier }}
run: |
set -euo pipefail
echo "tier: ${TIER:-<plan did not run>}"
echo "$NEEDS" | jq -r 'to_entries[] | " \(.key): \(.value.result)"'
plan_result="$(echo "$NEEDS" | jq -r '.plan.result')"
if [ "$plan_result" != "success" ]; then
echo "::error::the plan job did not succeed ($plan_result); nothing below it ran, so this is a red run, not a green one."
exit 1
fi
bad="$(echo "$NEEDS" | jq -r 'to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key')"
if [ -n "$bad" ]; then
echo "::error::failed or cancelled: $(echo "$bad" | tr '\n' ' ')"
exit 1
fi
ran="$(echo "$NEEDS" | jq -r 'to_entries[] | select(.value.result == "success") | .key' | wc -l)"
echo "OK: $ran job(s) passed, the rest were turned off by the plan."