Skip to content

ci: add live test suite workflow - #681

Draft
0xisk wants to merge 31 commits into
mainfrom
ci/live-test-workflow
Draft

ci: add live test suite workflow#681
0xisk wants to merge 31 commits into
mainfrom
ci/live-test-workflow

Conversation

@0xisk

@0xisk 0xisk commented Jul 15, 2026

Copy link
Copy Markdown
Member

Types of changes

What types of changes does your code introduce to OpenZeppelin Midnight Contracts?

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation Update (if none of the other choices apply)

Split out of #673 per review: the live CI workflow is large and complex, so it
lands on its own instead of alongside the test harness.

Stacked PR. Based on test/multisig-live-harness (#673), so the diff here
is just live.yml and its "Live tests in CI" CONTRIBUTING subsection. Once
#673 merges to main, this retargets to main automatically.

Adds .github/workflows/live.yml, which runs the live suite separately from
the required PR checks:

  • Nightly on main (regression net; a failure opens/comments a live-nightly tracking issue that auto-closes on the next green run).
  • On demand via workflow_dispatch, optionally scoped with category / filter inputs.
  • On a PR via the live-tests label.
  • A plan job reads the live-ready categories from the runner and fans out one job per category, each with its own stack and 6-hour job budget.
  • Never a required check.

PR Checklist

  • I have read the Contributing Guide
  • I have added tests that prove my fix is effective or that my feature works. N/A: CI workflow.
  • I have added documentation for new methods or changes to existing method behavior.
  • CI Workflows Are Passing

Signed-off-by: 0xisk <0xisk@proton.me>
@0xisk
0xisk requested review from a team as code owners July 15, 2026 12:58
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/live-test-workflow

Comment @coderabbitai help to get the list of available commands.

@0xisk
0xisk marked this pull request as draft July 15, 2026 13:00
@0xisk 0xisk self-assigned this Jul 15, 2026
@0xisk
0xisk force-pushed the test/multisig-live-harness branch 2 times, most recently from a45bda7 to 7f6e09e Compare July 17, 2026 16:14
Base automatically changed from test/multisig-live-harness to main July 18, 2026 03:52
@0xisk
0xisk force-pushed the ci/live-test-workflow branch from 4ea60c7 to 0ffe584 Compare July 20, 2026 09:37
0xisk added 2 commits August 24, 2026 12:07
The live workflow needs two decisions that are not the runner's: which
targets to fan out into matrix jobs, and what a nightly result does to
the `live-nightly` tracking issue. Both were `run:` shell inside the
workflow, where nothing can test them and nothing can run them locally,
so a mistake in either stays hidden until a nightly reports the wrong
thing.

`scripts/live-ci.ts` takes them over, following `test-live.ts`: node
runs the .ts directly and it imports only node builtins, so the jobs
calling it need a checkout and a Node, not an install.

* `ci/matrix.ts` resolves a requested scope against the runner's own
  target list, so the workflow keeps no copy of it. An unknown name is
  rejected in the plan job, naming the valid ones, instead of dying
  hours later inside the runner.
* `ci/nightly.ts` decides open / comment / close, including the case
  the shell version also had to special-case: a suite job the plan job
  never reached reports `skipped`, and a nightly that tested nothing is
  a failed nightly.
* `ci/gh.ts` builds every `gh` call as argv, so a markdown issue body is
  never re-interpreted by a shell.

Extracted as node-run TS rather than shell scripts: the repo has no bash
test harness, and this reuses the vitest project the live orchestrator's
own tests already run under.

That project had no runner of its own, though: no package script, no
turbo task, no CI step. Wire it up as `yarn test:scripts` and run it in
the test workflow, so these tests and `scripts/live`'s execute on every
PR.
The live suite runs against a real local stack with real ZK proofs, so a
run takes hours rather than minutes. Give it its own workflow instead of
a place in the required PR checks:

* nightly on main, as the regression net (a failure opens or comments on
  a `live-nightly` tracking issue, which closes on the next green run)
* on demand via workflow_dispatch, optionally scoped to one target or to
  a file filter within it
* on a PR, opt-in, by applying the `live-tests` label

A plan job asks the runner which live targets exist and fans out one job
per target, so each gets its own runner, its own stack, and its own
6-hour job budget. Never a required check.

The `target` input is a free-form string rather than a `choice`: the
valid set is whatever `scripts/live/targets.ts` discovers, and a
dropdown enumerated here would go stale as categories join. The plan job
validates the value and fails in seconds with the valid names.

Every `run:` is a one-liner. The two that are not runner invocations
call `scripts/live-ci.ts`, which is unit tested.

Split out of #673 per review: the workflow is large enough to land on
its own rather than alongside the test harness.
@0xisk
0xisk force-pushed the ci/live-test-workflow branch from 0ffe584 to 19e275b Compare August 24, 2026 10:23
0xisk added 3 commits August 24, 2026 12:37
Five items from the review of this branch. The first two share
`ci/matrix.ts`, its tests, and `live.yml`, so they land together.

* A file filter now narrows the matrix, not just the run. A filter is a
  substring of a path, so it matches some targets and not others, and a
  target that runs no file is an infrastructure abort in the runner
  rather than a pass. `target: all` with `filter: Forwarder` used to
  queue a red job for every target the filter misses, each paying for
  `env-up` and a compile first. A filter that matches nothing anywhere
  now fails the plan job in seconds. `ci/specs.ts` lists a target's
  spec files for that check.
* The `live-tests` PR label fanned out every target, which is 7 jobs of
  up to 6 hours for one label. `live-tests:<target>` scopes it to one,
  and the bare label still means all of them.
* `scripts/` was never type-checked: `yarn types` covers only
  `contracts/src/**/test/witnesses`, and both ways these files run
  (node's type stripping, vitest's transform) discard types without
  checking them. Add `scripts/tsconfig.json` and a `types:scripts`
  step to `yarn types`, so a type error in the orchestrator or in these
  CI helpers is caught on every PR.
* An unreadable `gh issue list` response threw a bare `SyntaxError`
  naming neither the command nor the output. It should not happen (`gh`
  keeps warnings on stderr), which is exactly why the message has to
  say what did.
* The `setOutput` tests mutated `process.env` directly. `vi.stubEnv`
  keeps the change scoped to the test.
Three findings from the second review, all about how a file filter is
matched.

The runner passed a name filter to vitest as-is, and vitest matches a
positional filter against the whole project include, which for
`unit-live` is every `src/**` spec. So `yarn test:live token Forwarder`
ran the multisig Forwarder spec too, and in CI, where each target is its
own job, every target the filter matched ran the same union of files and
reported it under its own name. Resolve the filter against the target's
own spec files instead and hand vitest explicit paths. A target the
filter matches nothing under is skipped rather than run: an empty filter
list would run the target's whole include glob.

`ci/specs.ts` moves to `live/specs.ts` for that: spec discovery belongs
to the runner, and the plan job and the runner now share one matching
rule (`filterSpecFiles`).

That rule is vitest's own, which is case-insensitive
(`TestProject.filterFiles` lowercases both sides). The plan job matched
case-sensitively, so `filter: forwarder` ran locally but was rejected in
CI as matching nothing.

A `live-tests:` label with nothing after the colon passes the workflow's
`startsWith` gate and used to resolve to the empty scope, meaning every
target: a malformed label would have queued the full fan-out. It is
rejected now, naming the valid targets.
@0xisk
0xisk marked this pull request as ready for review August 24, 2026 12:14
Fork-only merge, so the live workflow can be exercised on real runners from
0xisk/compact-contracts. The fork's main carried a one-line .gitattributes
predating upstream's; upstream's version is a superset of it (same
linguist-language line, plus linguist-detectable and the reasoning), so the
conflict resolves to upstream's file.
@0xisk
0xisk marked this pull request as draft August 24, 2026 13:12
0xisk added 5 commits August 24, 2026 15:13
ci: add live test suite workflow
A full fan-out on a cold compiler cache loses jobs to setup, not to tests.
Observed on a 7-target run: `live-security` died in `Setup Environment`
with "Failed to update ... Error while fetching compact releases /
Serde Error: expected value at line 1 column 1", then "No default
compiler set", while the other six went on to run.

`setup-compact-action` installs the compiler only on a cache miss, but
runs `compact update` unconditionally, and that query hits the GitHub
API. On a cache hit the query may fail harmlessly, since the action
swallows it and the compiler is already installed. On a miss there is
nothing to fall back on, so the same throttle kills the job. Seven jobs
starting at once means seven cold installs racing that one API.

Add a short `warm-compiler-cache` job between `plan` and the matrix that
installs the compiler once, so every suite job starts from a cache hit
and stays on the tolerant path. It is `continue-on-error`: if it fails,
the matrix still runs and each job installs its own compiler, exactly as
before.

The composite setup action gains a `compact-only` input for it, which
keeps the compiler version in one place. It also skips the turbo cache
step: that entry is keyed on the commit, so saving it from a job that
compiles nothing would leave every later job in the run restoring an
empty cache instead of falling back to a populated one from an earlier
commit.
`compile:integration` declared no compile dependency and took its inputs
from `test/integration/**/*.compact` alone, but the mocks under
`test/integration/_mocks/` import `src/` sources directly:
ConfidentialFungibleToken, its PublicSupply extension, FungibleToken,
NonFungibleToken, crypto/ElGamal, security/Initializable.

So a change to any of those left the task hash untouched and turbo served
the previous mock artifacts from cache. The composed contract under test
was then built from stale sources while the plain src artifacts beside it
were current.

Depending on `compile` fixes the invalidation: turbo folds a dependency's
task hash into the dependent's, so any src `.compact` edit now reaches
`compile:integration` through the category task that owns it. Naming the
aggregate rather than the individual categories also keeps a future mock
import from silently reintroducing the gap.

Consequence for the dry path: `yarn test:integration` now compiles all of
`src` first. It runs under `SKIP_ZK=true`, so this is source compilation
with no key generation, and every category is already a cache hit from
`yarn test`.
0xisk added 14 commits August 25, 2026 05:52
Every live run compiled the whole repo with real proving keys, so each
CI matrix job paid ~20 minutes of key generation for six categories it
never deploys: on the 7-target fan-out that is 7 full-key compiles per
run, and locally `yarn test:live crypto` spent 1 minute on its own
artifacts and 10+ on everyone else's.

Resolve the plan into a compile scope instead (`compileScope` in
targets.ts) and hand that to the ArtifactCompiler:

* a scoped category runs `compile:<category>`; turbo's `dependsOn`
  builds the categories the slice imports, and nothing else. Cross-
  category invalidation is sound since the compile:integration
  dependency fix (previous commit); the remaining category edges were
  verified against the actual `.compact` imports.
* the integration target runs `compile:integration` alone, which now
  pulls the full `compile` through turbo.
* an unscoped run keeps today's behavior (`compile`, verify `src/`).

The truncated-key scan narrows with it, to the trees the plan's specs
deploy: composition is compile-time, so dependency artifacts are build
byproducts nothing deploys, and scanning them from an unrelated target
could only false-positive on another slice's stale keys. The one
cross-category deploy is encoded (token fixtures deploy the crypto
mocks MockElGamal/MockEcdhMask), verified by grepping every category's
artifact references.

Per-target compile also keeps each matrix job's failure isolated: a
compile error in one category no longer costs the other six jobs their
compile step, and each job starts testing as soon as its own slice is
built, with no cross-job barrier.
One job per target meant one long serial run per target: the 2026-08-24
nightly spent 5.75h in `live-token`, and its verdict named a category
rather than a file. The spec files do not need to share a job — every
one of them resets the stack anyway — so run them apart.

Three layers now:

* plan resolves both matrices, publishing `targets` (7) and one leg per
  spec file (46). Legs carry the full spec path, which matches exactly
  one file, and a name unique within the target: the shared directory
  prefix is stripped, so `multisig` gets `ForwarderPrivate` and
  `presets-ForwarderPrivate` rather than one name for two jobs.
* compile builds each target's slice once with real ZK keys and uploads
  it. Without this layer the per-file fan-out would repeat a target's
  key generation once per file, 16 times over for `token`.
* the suite runs one spec file per job against the downloaded tree.

Runner-side, `--compile-only` builds the plan's scope and stops, and
`--prebuilt` verifies a tree it did not build and refuses to build one.
Prebuilt verification adds a completeness check (`missingKeyArtifacts`)
next to the truncated-key scan: a partial download leaves contracts
absent rather than 0-byte, which the key scan alone reads as clean, and
both end as a deploy failure in `beforeAll` that vitest turns into a
silent whole-suite skip.

Also here, because the fan-out forced them:

* Turbo cache keys are per target. One key shared by seven compile jobs
  is a write race: they all save at the end, one wins, and everyone
  else's slice is lost to whoever restores next.
* The nightly report reads `needs.compile.result` too. A compile
  failure skips the suite, and a skipped suite under a green plan used
  to report as `skipped` — a broken build silent until someone opened
  the Actions tab.
* `warm-compiler-cache` is gone: the compile layer installs the
  compiler once for the whole run, which is what that job existed to
  do. Its `compact-only` setup input goes with it, replaced by
  `turbo-cache-key`.

`needs` is job-level in Actions, so the suite matrix waits for the whole
compile matrix rather than for its own target's job. That barrier is the
slowest compile against a suite measured in hours, and it buys every job
byte-identical artifacts.
One compile matrix feeding one suite matrix made every spec file wait
for the slowest compile of every target, because `needs` in Actions is
job-level and there is no per-matrix-leg dependency edge. Run
32810453735 shows the cost: `utils` compiled in 41s, `crypto` in 67s and
`security` in 82s, while `token` and `integration` ran for many minutes,
so 15 spec jobs sat idle behind targets they share nothing with.

Pair each target instead: `compile-<target>` feeds `live-<target>`, a
matrix over that target's spec files. A target's specs now start the
moment that target is built, and a target whose compile fails skips its
own spec jobs and no others.

The pairs have to be literal YAML, so the steps move into two reusable
workflows (`live-compile.yml`, `live-suite.yml`) and the 14 jobs stay
declarations rather than 14 copies of a job body. The plan job publishes
one `legs-<target>` output per target, since output names must be
literals too.

The one property this gives up is the fully dynamic target list: a new
`src/` category no longer joins the live run on its own. `KNOWN_TARGETS`
in the workflow is what it must be added to, and the plan job now fails
in seconds naming any discovered target with no pair, so the gap cannot
be silent.

Also here:

* `worstResult` collapses the seven per-target results the nightly
  report now receives as a joined list. Severity order matters: one
  failure outweighs six passing targets, cancelled only wins when
  nothing failed, and a mix of success and skipped is a success, since
  a scoped run legitimately skips the other six targets.
* Hyphenated names inside expressions use bracket notation
  (`needs['compile-token']`): `-` is also the subtraction operator, so a
  hyphenated name after a `.` is ambiguous by grammar.
* Each `live-<target>` job carries `${{ matrix.name }}` in its name. A
  caller job with a fixed name renders every leg of its matrix
  identically, which would hide which spec a red job ran.
* Reusable workflows do not inherit the caller's `env`, so
  `TURBO_TELEMETRY_DISABLED` is set in both of them.
`unit-live` excludes `src/**/test/witnesses/**` (LIVE_EXCLUDE in
vitest.config): those specs build a fabricated WitnessContext and assert
on the private-state helpers directly, so a live backend changes nothing
about them.

`specFiles()` did not know that. A target-wide run never noticed, since
vitest applied its own exclude and filtered them out silently, but CI now
gives each spec file its own job — and a job whose only file is excluded
matches nothing, which the runner reports as an infrastructure abort:

  No test files found, exiting with code 0
  no test file matched across token (filter: src/token/test/witnesses/
  ConfidentialFungibleTokenWitnesses.test.ts) — nothing ran, so there is
  no result to report.
  ##[error]Process completed with exit code 2

Observed on run 32811648017, which would have failed all seven of them
(3 under access, 4 under token). Apply the exclude where the file list is
produced, so the matrix can only ever name a file its project runs: 46
legs become 39.
`compile-integration` died in `Setup Environment` on run 32812144831:

  API rate limit exceeded for 20.25.10.69
  Error: Failed to update ... Error: Failed to run compactc
  Caused by: No default compiler set

Not a cache miss. The job restored `compact-0.31.0-Linux-X64` fine.
`setup-compact-action` passes `github.token` to its install step but not
to its update step, so the `compact update` it runs on every job goes out
unauthenticated: 60 requests an hour per runner egress IP, shared by
every job starting at once.

A cache hit is the fragile path here, not the safe one. It skips the
install step, so nothing has set the default compiler, and `compact
update` is what sets it. `|| true` swallows the update's own failure and
the following `compact compile --version` is what fails the job. Six
targets got their API call in before the ceiling; the seventh did not.

Exporting `GITHUB_TOKEN` on the compile job authenticates that query
(1000 requests an hour) — a composite action's steps inherit the calling
job's environment, which the failing log confirms by echoing this
workflow's `TURBO_TELEMETRY_DISABLED` inside the action's own step. The
upstream fix is for the action's update step to take `github.token` the
way its install step already does.

The suite jobs need nothing here: they pass `skip-compact` already, so
only the compile jobs touch that API at all.
`setup-node`'s `cache: yarn` restores `.yarn/cache` alone, so every job
unpacked it into `node_modules` and rebuilt the native modules from
source. Run 32815780786 shows it in each compile job:

  Cache hit for: node-cache-Linux-x64-yarn-...
  yarn install --immutable
    msgpackr-extract must be built because it never has been before
    classic-level    must be built ...
    cpu-features     must be built ...
    ssh2             must be built ...
    Completed in 23s 516ms

24s of a 38s setup, repeated in all 46 jobs the live run now fans out
into, plus every job of checks.yml and test.yml.

Cache `node_modules` (and the workspace's) alongside the zips instead, so
the install has nothing to do. Measured with zstd, the level
actions/cache uses: `node_modules` is 137 MB against `.yarn/cache`'s
138 MB, so the entry roughly doubles in size to buy back the whole
install. That trade is clearly right — the turbo cache is already 275 MB
and restores in about 3s.

The zips stay in the entry because they are what a miss needs: dropping
them would send every job of a lockfile-changing run to the network. The
key is exact, with no `restore-keys`, since a tree restored from a
different lockfile is worse than no tree — `--immutable` would only have
to undo it. A miss therefore behaves exactly as it did before.

`yarn install --immutable` still runs on a hit. It is a fast no-op there,
and it is what makes a partial restore fail in setup rather than inside a
spec.
A commit-keyed entry is written on every push whether or not anything it
caches changed, and these entries are large: `token`'s slice is ~275 MB
of ZK keys and `multisig`'s ~213 MB. Five commits had therefore left five
copies of each, and the repo sat at 6.5 GB of its 10 GB budget with a
3.8 GB entry still there from before the keys went per-target.

That ceiling is not harmless. Past it GitHub evicts least-recently-used,
which would drop the dependency-tree cache added in the previous commit
and quietly undo the 24s per job it saves.

Key on the contract sources and the pinned compiler version instead, so
a docs-only or test-only commit reuses the existing entry rather than
duplicating it. `restore-keys` stays: a source change should start from
the previous build, and turbo's store is content-addressed, so every task
whose own inputs did not move is still a hit.

The stale 3.8 GB entry and the 30 commit-keyed ones are deleted; the repo
is at 0.4 GB.
Restoring `node_modules` took the install from 24s to 9s, but run
32818574868 still reported every native module as needing a build on a
full cache hit:

  Cache restored from key: Linux-deps-cf82a765...
  msgpackr-extract must be built because it never has been before
  classic-level    must be built ...
  cpu-features     must be built ...
  ssh2             must be built ...
  Done in 9s 266ms

The record of a completed build is not in `node_modules`. Yarn 4 keeps it
in `.yarn/install-state.gz`, which the entry did not cover, so every job
recompiled the same five packages from source with nothing to tell it
they were already done.

It is 534 KB, so adding it costs nothing measurable and removes the last
of the install.
`compile:integration` depended on `compile`, so the job that builds five
mock files first generated real ZK keys for all 68 sources under `src`.
On run 32819706075 that made `compile-integration` the only compile job
still running after seven minutes while its six siblings finished inside
a minute, and it is the barrier the whole integration lane waits on.

The dependency was never needed for the build. `compile:integration` is
`compact-compiler --src test/integration/_mocks`, and the Compact
compiler follows each mock's imports into `src/*.compact` from source,
emitting one artifact per top-level file. It reads sources, not sibling
artifacts. The integration specs then deploy only the `Composed*` mocks.

It was needed for cache invalidation, which is what the dependency was
added for: the mocks import `src/token`, `src/crypto` and
`src/security`, so a change there must rebuild them, and the task's
`inputs` covered only `test/integration/**`. Naming those sources as
inputs achieves the same invalidation directly — a src `.compact` edit
changes the task hash — without dragging six categories of key
generation into the job.

Conservative on purpose: any src change invalidates the mocks, including
one they do not import. That is the safe direction, and it costs a
five-file rebuild.
A live test costs ~55s (deploy-per-test against a real node), so one
big spec file is one long serial job: ShieldedAccessControl.test.ts
(89 tests) ran 85+ minutes and set the whole run's wall clock, and
Signer.test.ts (57 tests) ran 48 minutes. Splitting per file cannot
help those; splitting within a file can.

New rule, driven only by MAX_TESTS_PER_LEG (scripts/ci/split.ts): a
file over 30 statically counted tests is parsed into its describe
tree and bin-packed by describe blocks into legs of at most 30, each
leg running the same file under a vitest -t (testNamePattern) regex.
The plan smoke now yields 63 legs instead of 46 (ShieldedAccessControl
4, Signer 2, nonFungibleToken 6, ...), the largest at 30 tests.

The filter format is pinned against the installed vitest 4.1.10: -t
becomes new RegExp(pattern) matched against the SPACE-joined describe
path plus test name (getTaskFullName in @vitest/runner), not the
" > " the reporters print. Each leg is an alternation of ^-anchored,
regex-escaped describe paths with a trailing space, so `grantRole `
matches neither _grantRole nor grantRoleExtra. Tests sitting beside
child describes get a remainder alternative: the parent path with a
lookahead excluding every literal child.

A wrong filter is worse than a long leg, so anything inexact falls
back to not splitting the file at all: a corrupted scan (unbalanced
brackets), a dynamic describe name (template `${}`, variable,
describe.each) whose remainder exceeds the limit, colliding sibling
prefixes, or unit counts that do not add back up to the file total.

The pattern threads as MIDNIGHT_LIVE_TEST_PATTERN: live-suite.yml
passes the leg's testFilter into the run step env, test-live.ts
validates it (new RegExp, else exit 2) and hands it to VitestRunner,
which appends -t to every spawn, so the round-2 flake re-run inherits
the same slice automatically. Vitest is silently green when -t
matches nothing (file "passed", all tests skipped), so the
orchestrator gains a guard: on a clean run the pattern must match at
least one reported test NAME. Names are reported for skipped tests
too, which is what tells a stale pattern (abort, exit 2) apart from a
slice that is legitimately all runtime-skipped on live (.skipIf,
itDryOnly) and still passes.

Known caveat, accepted: packing counts statically, so an it.each or
describe.each table counts once and property-based files under-split
relative to their runtime test count. Packing is a budget, not an
invariant; every test still runs exactly once.
actions/cache never overwrites an existing key, so a change to the
cached path list keeps restoring the old, incomplete entry until the
lockfile happens to move. A manual v1 token in the key is what gets
bumped with any path-list change.

A follow-up step turns a miss into a ::warning:: annotation: expected
only after a lockfile change, anything else means eviction or key
drift, and the job silently pays the full install otherwise.
With one suite job per spec file (and per split slice), the bare label
fans out into 60+ checks on the PR — never what casually applying a
label means. The plan job now rejects it with the scoped
live-tests:<target> form and the valid target names; the full fan-out
stays reachable via a deliberate dispatch with target 'all'.

The workflow gate keeps accepting the bare label on purpose, so the
author sees the red plan job with the message instead of silence.
GitHub evicts least-recently-used past the 10 GB repo budget, which is
the worst policy for this workflow: the evicted entry is whichever one
the next run needed. The nightly report job now prunes deliberately
instead (scripts/ci/caches.ts, pure decision + gh api adapter):

* an entry superseded within its group — same hash-stripped key prefix
  AND same ref, since restores are branch-scoped — is dead weight the
  moment a newer sibling exists (actions/cache restores the exact key
  or the newest prefix match, never an older one);
* any entry not restored in 7 days goes whatever its group looks like.

The step runs after the tracking-issue report on purpose, so a gh
hiccup can never block the report, and the job gains actions: write.
Run 32831811290's 11 failed legs were all known-deterministic
rejections — the block-limit deploy ceiling (node error 1010) and the
unclaimed-shielded-output rule (ledger Custom error: 186, audit H-03).
Both are properties of the transaction, not of node state, so the
round-2 re-run on a fresh node returns the same verdict and only
doubles the loss (a second stack reset plus the full re-run).

A file now skips round 2 when EVERY failed test in its round-1 report
matches one of the patterns in scripts/live/deterministic.ts, and
reports REAL immediately with the cause named in the verdict block
(exit code unchanged). Any unmatched or message-less failure — hook
crashes report no failed assertions at all — keeps the file on today's
round-2 path, so an unknown failure never loses its flake check.
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
Comment thread .github/workflows/live.yml Fixed
0xisk added 3 commits August 25, 2026 16:06
The split rule budgeted legs by test count, but a live test's cost is
its transaction count times ~18s of indexer finality, which varies 3x
across files: run 32831811290's MultiToken legs of <=30 tests ran
70-91 minutes (~3 min/test) while most files run ~1 min/test. Count
budgeting under-split exactly the heavy files.

The plan job now downloads the newest completed run's live-reports-*
artifacts (best-effort: gh CLI only, continue-on-error, and the
fetch command itself never fails — no history means count packing)
and the packer weighs each test by its measured duration:

* scripts/ci/weights.ts — reports → per-file (fullName → max ms),
  suffix-matched to the plan's contracts-relative paths so same-named
  describes in different files cannot inflate each other;
* scripts/ci/history.ts — the gh run list/download adapter;
* scripts/ci/split.ts — the budget becomes MAX_LEG_MS, derived as
  MAX_TESTS_PER_LEG x DEFAULT_TEST_MS so that a file with no matching
  history packs EXACTLY as the old count rule did (uniform weights
  scale every comparison; pinned by tests and by a byte-identical
  legs-output diff against the old code). An indivisible unit whose
  MEASURED weight exceeds the budget becomes its own oversized leg —
  refusing, as the count rule does for assumed weights, would collapse
  the whole file into one longer leg (MultiToken: 12 legs vs 331 min);
* the plan log prints each leg's estimated minutes; the estimate is
  stripped before setOutput, so the published matrix shape and the
  suite workflow inputs are unchanged.

With the fabricated 3 min/test history, MultiToken fans out into 12
legs (largest ~42m, an oversized measured leaf) instead of 5 count
legs of up to ~90 min.
The label path could only name one target, so asking a PR for the full
fan-out meant one label per target. The concurrency group is keyed by PR
and event rather than by label, so those seven runs cancelled each other
down to whichever label was applied last.

`all` was already the dispatch input's word for every target; it just
never reached the label path, which handed its suffix straight to the
target lookup and rejected `all` as an unknown one.

* map the label suffix `all` to "every target", as the dispatch input does
* point the bare-label rejection at `live-tests:all` rather than at a
  dispatch, since the label form now covers it
CodeQL flags a job that inherits the repository's default GITHUB_TOKEN
scope, and the 14 target pipelines are `uses:` declarations with no
`permissions:` block of their own — one alert each on #681.

Set the floor once at the workflow level rather than repeating an
identical block across 14 jobs whose whole point is to stay one-liners.
`plan` and `report-nightly` keep the wider scopes they declare, since a
job-level block replaces the workflow one rather than adding to it, and
both reusable workflows already pin `contents: read` on their own jobs.
@0xisk 0xisk added the live-tests:all Run the full live suite on this PR label Aug 26, 2026
0xisk and others added 2 commits August 27, 2026 15:06
The org shares 60 concurrent jobs on the Team plan and run 32941225488
peaked at 58 of them for about an hour, so an uncapped live run locks
every other repo's CI out. Actions has no global cap, only per-matrix,
so the bound is the sum of the seven per-target caps: ~28 concurrent on
the nightly, ~14 on a PR label or dispatch. Held-back legs queue without
holding a slot. Wall clock stretches to roughly 3-4h from 1.7h; the
350-minute budget is per leg, so it is unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

live-tests:all Run the full live suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants