Skip to content

Rehome the symbols the dead set still owes surviving code (#227) #643

Rehome the symbols the dead set still owes surviving code (#227)

Rehome the symbols the dead set still owes surviving code (#227) #643

Workflow file for this run

# The whole quality gate, run automatically. Until this file existed, `pnpm run
# verify` only ran when a human remembered to (or at deploy time, which can be
# days after the breaking merge) - see issue #54.
#
# `verify` no longer runs as one job, because the test phase is ~95% of it and
# a runner is ~3x slower than a dev machine: the single job measured 9m03s on
# PR #116. The phases are now split across a `static` job and a sharded `tests`
# matrix that run in parallel, with a `verify` job aggregating them.
#
# The jobs here are `static`, `tests` (4 shards), `rust`, `verify` and `build`.
# Only `verify` and `build` are REQUIRED status checks in ruleset `EJ`. `rust`
# is deliberately not one, and its absence from the ruleset is not an oversight
# to fix: `verify` asserts `needs.rust.result`, so a red `rust` job turns the
# required check red anyway, with no ruleset PUT and no two-step. Every
# required NAME is a permanent liability - rename it and every PR blocks
# forever on a check that cannot run - so the aggregator absorbing new phases
# is the cheaper shape. Add future phases the same way.
#
# The anti-drift rule that motivated the old VERBATIM comment still holds, and
# is now enforced differently: this file names only package.json SCRIPTS
# (`verify:static`, `verify:shard`), never the underlying commands. `verify`,
# `verify:static` and `verify:shard` are all composed from the same
# `verify:lint` script, so there is still exactly one definition of each phase
# and CI cannot drift from local. Do not inline `vp check` etc. here.
# Note it is never `check` - that is `vp check --fix` and CI must never rewrite
# files.
#
# WHY THE JOB NAMED `verify` STILL EXISTS: ruleset `EJ` requires status checks
# named `verify` and `build`. A required check that never appears blocks every
# PR forever, so the aggregating job below MUST keep the id `verify`. Renaming
# it needs a ruleset PUT in the same change - see CLAUDE.md on the two-step.
#
# WHY 4 SHARDS, MEASURED ON CI (issue #119, 2026-08-10). This said 3 until then,
# on the reasoning that `test/previewAgreement.spec.ts` was "67s of the 68s local
# suite" and therefore an unsplittable floor that N=4+ could not beat. Both
# halves of that are now false, and the second one was never measurable locally:
# a dev box has 12 cores, a runner has 4, so locally the CPU term is absorbed and
# only the file floor is visible.
#
# One run, all four arms concurrently on the same runner pool (18 jobs), so the
# comparison is within-run rather than against a different hour. Slowest shard is
# what the gate waits on:
#
# N=3 test-step walls 452 / 356 / 102 slowest job 488s
# N=4 306 / 291 / 291 / 72 slowest job 333s <- adopted
# N=5 311 / 213 / 174 / 74 / 67 343s
# N=6 314 / 224 / 162 / 140 / 61 / 51 344s
#
# N=4 is -155s of gate wall (-32%) for +76 runner-seconds (+7.6%), NOT the +33%
# the local numbers predicted - total CPU is flat and only ~28s of per-job setup
# is added. N=5 and N=6 do not improve on N=4 at all (+5s, +8s: noise), so 4 is
# the point of diminishing return.
#
# The plateau at ~310s is NOT the previewAgreement file. That was checked rather
# than assumed: the slowest N=6 shard (33 files, 314s) does not contain it - the
# same shard run locally lists cliffOreCascade / vulcanusCliffBands /
# cliffCollisionResidualShape as its heavy files. The premise expired because the
# suite grew 171 -> 201 files through the #84 cliff work: previewAgreement is now
# 72.9s of 503s of total per-file wall (14.5%), and TEN files exceed 20s. So
# splitting that one file is no longer the lever it was; the binding shard is
# whichever one vitest's hash-split happens to load with several heavy files.
# Re-measure before raising N again - and re-measure on CI, not locally.
#
# The production build runs too, in the separate `build` job below - see its
# own comment for why it is not a phase inside `verify`.
#
# What is deliberately NOT here:
# - `pnpm refs:sync`. It needs a Factorio binary and ~/GitHub/factorio-data,
# neither of which exists on a runner. `verify` is designed to pass with no
# Factorio installed and that property is what makes this workflow possible.
# - Any deploy step or credential. Cloudflare Pages does not build this repo
# (`deploy:app` uploads an already-built `dist`), so CI here is a check
# only and the job needs no secrets at all.
name: verify
on:
push:
branches: [main]
pull_request:
# So a run can be forced without an empty commit.
workflow_dispatch:
# Superseded pushes to the same PR are pointless at ~4 minutes a run, so those
# get cancelled. Pushes to `main` do NOT - `cancel-in-progress` there means a
# second merge kills the first one's run, and `main` is the branch the deploy
# ships from, so every commit on it should carry its own verdict.
#
# This is not hypothetical: on 2026-07-30 six PRs merged inside half an hour and
# the runs for `605a4cc` and `be5f592` were both cancelled by the merges that
# followed them. Neither commit is unverified in practice - each had a green
# `verify` on its own PR before the ruleset would let it merge - but "green on
# the PR" and "green as it sits on main" are different claims, and only the
# second one survives a rebase-free merge into a `main` that has since moved.
# Ruleset `EJ` has since set `strict_required_status_checks_policy: true`, which
# closes most of that gap by forcing a PR up to date before it can merge; a
# per-commit verdict on `main` is still the thing that proves it, so these runs
# stay uncancelled.
concurrency:
group: verify-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Minimum scope: the job only reads the tree. It writes no statuses, comments,
# packages or releases, so nothing beyond `contents: read` is granted.
permissions:
contents: read
jobs:
# Everything in `verify` that is NOT the app test suite: `vp check`,
# `check:vue`, and `preview:test`. 55-80s of job wall over the last six runs
# (this said "~24s" and was never re-measured; ~28s of that is checkout +
# install, which every job here pays). Still finishes long before any shard,
# so it costs nothing to run as its own job.
static:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# `preview:test` shells out to wrangler (`wrangler types --check`). Keep it
# from phoning home for telemetry on a runner where nobody can answer the
# opt-in prompt.
WRANGLER_SEND_METRICS: "false"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run verify:static
# The app test suite, split across runners. Each shard is a separate machine,
# which is the ONLY way to raise the parallelism ceiling - `maxWorkers` was
# already measured as a dead end on one box (see CLAUDE.md).
tests:
runs-on: ubuntu-latest
# The slowest shard measures ~5m30s (see the shard table above). 15 minutes
# stays a hang detector rather than a budget.
timeout-minutes: 15
strategy:
# One failing shard must not cancel the others: a red `verify` should say
# everything that is broken, not just whichever shard tripped first.
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4]
shardTotal: [4]
steps:
# Third-party actions are pinned to a full commit SHA, never a moving tag.
# The trailing comment names the release each SHA is, and Renovate updates
# the SHA and that comment together - see .github/renovate.json5.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# No `version:` input on purpose. pnpm/action-setup >= 6 reads
# `devEngines.packageManager` from package.json, so the pnpm pin stays in
# exactly one place. Hard-coding it here would be a second pin to drift.
# This step must precede setup-node: `cache: pnpm` below resolves the
# store path by running pnpm, so pnpm has to be on PATH already.
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
# `.node-version` (26.7.0) is the single source of truth for the Node
# version, and this is its first real consumer - the file went from
# documentation to machinery when this workflow landed. `engines.node`
# stays a permissive floor and is deliberately NOT what CI runs.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: pnpm
# A full workspace install. Anything narrower can leave a sibling
# workspace's symlink dangling and produce fake
# `TS2307: Cannot find module 'vitest'` errors; a real full install is the
# one that reports `Scope: all 3 workspace projects`.
- run: pnpm install --frozen-lockfile
- run: pnpm run verify:shard -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
# The Rust half of the gate: cargo fmt, clippy, tests, the zero-dependency
# assertion, and the byte comparison against the committed engine.wasm.
#
# It is NOT a required status check of its own, and that is the point. Ruleset
# `EJ` matches required checks by NAME, so every name added is a name that
# blocks every PR forever if it is ever renamed or removed. The `verify` job
# below already exists to aggregate, so asserting one more `needs.*.result`
# there gets identical blocking behaviour with no ruleset PUT and no two-step.
# See CLAUDE.md for when a genuinely new required name is warranted.
#
# No toolchain action, pinned or otherwise: `rust-toolchain.toml` pins 1.97.1
# with rustfmt, clippy and the wasm32 target, and rustup installs that on the
# first cargo command. Expect a `syncing channel updates` line in the log even
# though the image already ships Cargo 1.97.1 - rustup treats `1.97.1-<host>`
# and `stable-<host>` as separate installs of the same compiler, so the pin
# costs one download per runner.
#
# Measured on this job's first run (#230): `info: syncing channel updates for
# 1.97.1-x86_64-unknown-linux-gnu`, `info: downloading 6 components`, 10s of
# the job's 19s. The whole job is still the cheapest in the workflow, and the
# runner reports `rustc 1.97.1 (8bab26f4f 2026-07-14)` - the same build as the
# dev machine, which is what makes the byte comparison below possible at all.
rust:
runs-on: ubuntu-latest
# The whole job is seconds of compiling two tiny crates on top of the
# toolchain sync. 15 minutes is a hang detector, matching the jobs above.
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Printed rather than assumed: if a byte comparison against the committed
# engine.wasm ever fails, the first question is whether the compiler
# moved, and that question needs an answer in the log of the run that
# failed - not a re-run months later against a different image.
- name: Show the resolved toolchain
run: |
rustup show
cargo --version
rustc --version --verbose
# A pinned release binary rather than `cargo install`, which builds from
# source and would dominate a job that is otherwise seconds - it took
# ~4 minutes locally. The checksum is this asset's own published sha256,
# so the binary is pinned by content the same way the actions above are
# pinned by commit SHA.
- name: Install cargo-deny 0.20.2
run: |
V=0.20.2
F=cargo-deny-$V-x86_64-unknown-linux-musl.tar.gz
curl -fsSLO "https://github.com/EmbarkStudios/cargo-deny/releases/download/$V/$F"
echo "$CARGO_DENY_SHA256 $F" | sha256sum -c -
tar -xzf "$F"
sudo mv "cargo-deny-$V-x86_64-unknown-linux-musl/cargo-deny" /usr/local/bin/
cargo deny --version
env:
CARGO_DENY_SHA256: 9f12ed4c49936e09b48bf862b595cde2fe64fcbd9d74dfacac6131ca824c8d5f
# The one deviation from "always name a package.json script", and it does
# not reopen the drift the rule guards against: `verify:rust` is exactly
# `bash scripts/verify-rust.sh`, so the SCRIPT FILE is the single
# definition and both sides run the same bytes of it. Going through pnpm
# would add a pnpm/action-setup + setup-node + `pnpm install` (~28s) to a
# job that needs no JavaScript at all, purely to shell out to this line.
# If `verify:rust` ever grows a second command, this must become
# `pnpm run verify:rust` with the setup steps restored.
- run: bash scripts/verify-rust.sh
# The required status check. Ruleset `EJ` requires a check named `verify`, so
# this job must keep that id even though the work now happens above.
#
# `!cancelled()` rather than `always()`: a cancelled run (superseded push, per
# the concurrency group) should stay cancelled, not be converted into a
# failure. But a FAILED dependency must fail this job - a `needs:` job whose
# dependency failed is otherwise skipped, and a skipped required check does
# not block a merge. Hence the explicit result assertions.
verify:
needs: [static, tests, rust]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Assert every phase passed
env:
STATIC: ${{ needs.static.result }}
TESTS: ${{ needs.tests.result }}
RUST: ${{ needs.rust.result }}
run: |
echo "static=$STATIC tests=$TESTS rust=$RUST"
# `tests` is the matrix aggregate: 'success' only if every shard was.
[ "$STATIC" = "success" ] || { echo "::error::static phase: $STATIC"; exit 1; }
[ "$TESTS" = "success" ] || { echo "::error::test shards: $TESTS"; exit 1; }
[ "$RUST" = "success" ] || { echo "::error::rust phase: $RUST"; exit 1; }
# `verify` is check + type-check + tests. None of them build, so a change that
# passes all three and breaks the production build reached `main` unnoticed
# until somebody deployed - possibly days later, and landing on whoever was
# deploying rather than whoever broke it (issue #61). This job closes that.
#
# Deliberately a SEPARATE job rather than an extra phase inside `verify`:
# `deploy` already runs `pnpm build` immediately after `pnpm run verify`, so
# folding the build into `verify` would build twice on every deploy and slow
# the local gate people actually run by hand. Separate also means it runs in
# parallel with the test job instead of after it.
build:
runs-on: ubuntu-latest
# The build is ~1s of Rolldown after ~30s of setup. 15 minutes is a hang
# detector, matching the job above.
timeout-minutes: 15
steps:
# Default `fetch-depth: 1` is correct here, and that was MEASURED rather
# than assumed - #61 was filed believing the build needed deeper history.
# `scripts/buildStamp.ts` runs exactly three git commands:
# `rev-parse HEAD`, `rev-parse --short HEAD`, and `status --porcelain`.
# None of them read history, so a shallow checkout is enough and the
# earlier "the build stamp reads git history" was imprecise - it reads git
# *state*.
#
# Worth knowing rather than fixing: on a `pull_request` event checkout
# lands on the merge commit, so the stamp this job produces is a synthetic
# SHA that exists nowhere in the repo. Harmless, because CI never deploys
# its artifact - `deploy:app` builds locally and uploads that. If a CI
# build ever becomes the thing that ships, this needs revisiting.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
# This catches a build that FAILS. It does not enforce "zero warnings",
# which CLAUDE.md leans on ("anything that does appear is new and worth
# reading"). Both ways of enforcing that were considered and rejected:
#
# - Grepping the build output for /warn/i. `vp build` has no
# `--fail-on-warn` (checked against vp 0.2.6 `--help`), and this repo
# has already been burned once by grepping build output - a version
# grep returned zero because the minifier had rewritten the string.
# A false pass here is worse than no check.
# - A `build.rollupOptions.onLog` hook that throws on `warn`. Robust, but
# it would hard-fail every local build too the moment a dependency
# emits one benign warning, and dependency-sourced warnings are exactly
# what this repo has seen (zlib-asm needed two suppressions before #46
# removed it). The value is in reading a new warning, not in blocking on
# it.
#
# So warnings stay visible in this job's log and unenforced. If that ever
# needs to change, the `onLog` route is the sturdy one - not a grep.
- run: pnpm vp build