fix: make compile output TypeScript-developer friendly #11637
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| # --------------------------------------------------------------------------- | |
| # 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." |