Two workflows do the developer-blocking work. ci.yml's build-test job runs
on every pull request, and coverage-main.yml's coverage-upload job runs on
every push to main. Both use the ubicloud-standard-4 runner label, which is
registered in .github/actionlint.yaml, and both declare timeout-minutes so
a hung step cannot bill to the platform's six-hour default.
The fork arm on the pull-request lane
A pull request from a fork cannot obtain an Ubicloud runner, so build-test
names its runner through an expression rather than a label:
runs-on: >-
${{ github.event.pull_request.head.repo.fork
&& 'ubuntu-latest' || 'ubicloud-standard-4' }}
Without the arm the job never starts on a fork's pull request, and a required check that never reports presents as a pull request waiting rather than as a placement fault.
coverage-upload keeps the plain label. It runs on push events, so a fork's
pull request cannot reach it, and a fallback arm there would be a branch
nothing ever takes: one more expression to keep correct, with no case that
exercises it. The contract asserts that absence as well as the two arms, so the
expression does not spread by imitation.
Keep the continuation at the same indent as the first line. A more-indented
line inside a folded scalar keeps its break, so the expression arrives with a
newline inside it. GitHub evaluates it anyway and the lane runs, which is why a
green run is not evidence that the declaration is well-formed;
no_runs_on_declaration_carries_a_line_break is what reads it.
The loader models the expression as a fourth runs-on shape carrying the guard
and both arms, so each arm stays a label for every rule that reads one. That
matters for the actionlint registry, which must be asked about the Ubicloud arm
and must not be asked about the hosted one. Only the prescribed spelling is
read: a negated guard or a comparison says the same thing with the arms
reversed, and reading either as the prescribed form would let two spellings
drift apart while both satisfied the contract.
Every other job stays on GitHub-hosted ubuntu-latest. That placement is a
rule, not an accident: delayed comments, metadata lookups, label handling, and
release orchestration are API-bound, so paid runner capacity buys them nothing
and their queue time is already short. dependabot-automerge.yml calls a
reusable workflow, which chooses its own runner.
non_build_jobs_stay_on_hosted_ubuntu_runners reads it as an exact label: the
job's own label must be ubuntu-latest, so ubuntu-24.04 is reported as well
as Windows, macOS and any paid label. Whether a label belongs to the hosted
Ubuntu family is a separate question, Job::stays_on_hosted_ubuntu, which this
rule does not ask.
Every one of these rules is a statement about a job that runs. None of them
reads whether it does, so a build lane carrying if: false, or
continue-on-error: true, keeps a valid runs-on, a bounded timeout-minutes
and a correct cache key while executing nothing or reporting success whatever
it found. no_scope_is_dead_or_advisory reads both fields at job and step
scope and refuses a constant, in any of its spellings. A condition that depends
on the event is not refused, because that is what if is for and
dependabot-automerge.yml declares one: the rule is that a scope must be able
to run, not that it must always run.
Tool installation
No tool is compiled from source. whitaker-installer is installed by
leynos/shared-actions/.github/actions/install-whitaker, which downloads the
pinned prebuilt release archive and verifies it against a digest pinned inside
the action, then runs the installer to place the Whitaker Dylint suite. Every
leynos/shared-actions reference pins a reviewed commit by value. The default
is SHARED_ACTIONS_SHA in tests/support/workflow_estate.rs; an action whose
reviewed revision differs is named in SHARED_ACTION_PIN_EXCEPTIONS, and
required_shared_action_sha resolves each reference to the commit it must
carry, matching the action by its own name so a lookalike is held to the
default. The rule is that no reference floats, not that every action moves
together.
The exceptions are install-whitaker and the two coverage actions.
install-whitaker is held to WHITAKER_ACTION_SHA, currently
6cec89bac47a21cf756d68d638a9a510998e57f8 (shared-actions #546). It advances
independently of the estate default because the concordat QG-002 rule accepts
any shared-actions commit at or after 6dea5677 (#522) that leaves the action
directory content-identical to it, so repinning it to the estate default would
break QG-002. The workflow passes installer-version: '0.2.9', the floor the
action accepts, and supply_chain.rs asserts it. The coverage actions,
generate-coverage and upload-codescene-coverage, share
COVERAGE_ACTIONS_SHA, currently a5765019912a8ab6882b12db049c7cde635f3a85,
so that they carry one commit (the shared CV-005 contract holds this as
coverage.selection-parity). At that revision the uploader verifies the
cs-coverage archive against its committed cli-manifest.json and rejects a
non-empty installer-checksum outright, so
tests/contracts/codescene_uploader.rs refuses that input and the
CODESCENE_CLI_SHA256 variable that fed it in any workflow, even in a comment,
requires every uploader reference to carry the approved commit, and requires the
get-codescene-sha refresh dispatch to stay deleted under either workflow
extension.
sccache is not installed by this repository: setup-rust owns it (see
The compiler cache).
Cache ownership
Each mutable path has exactly one owner, so no two steps race to write it and every miss is explainable from the rendered key.
| Path | Owner | Key inputs |
|---|---|---|
~/.cargo/registry, ~/.cargo/git |
setup-rust (cache-provider: github) |
runner.os, rust-toolchain.toml and Cargo.lock hash |
~/.cargo/bin/whitaker-installer, its version marker, ~/.local/share/whitaker |
install-whitaker (cache-provider: github) |
runner.os, runner.arch, installer version, dylint.toml hash |
.uv-cache, .uv-tools |
the Cache uv tool layers step in ci.yml |
runner.os, runner.arch, runner.environment, Makefile and scripts/*.py hash |
| coverage ratchet baseline files | generate-coverage's split restore and save |
runner.os, run id |
| compiler output | sccache, through its GitHub Actions backend |
compiler flags and toolchain, hashed by sccache itself |
Table 1: Cache ownership and cache-key inputs.
generate-coverage is called with cache-provider: external in both jobs
because setup-rust already owns the Cargo registry and Git index; without
that input the action would become a second owner of the same two paths. For
the same reason no step archives a target tree. Every actions/cache
reference written in these workflow files pins
55cc8345863c7cc4c66a329aec7e433d2d1c52a9 (v6.1.0). That claim covers the
workflow files only. A shared action may reach an actions/cache reference of
its own, and upload-codescene-coverage does; the next paragraph records it.
The compiler cache
sccache owns compiler output, and nothing archives a target tree. Both build
jobs let setup-rust own it, through one pinned call whose position and inputs
are the contract.
setup-rust installs sccache, selects the backend by runner, starts the server
with a 60 s startup timeout, and names sccache as the rustc wrapper. On
Ubicloud it exports the cache-proxy credentials itself and keeps
ACTIONS_CACHE_SERVICE_V2 cleared after the mozilla sccache-action runs; on a
GitHub-hosted runner it takes local disk. If the server still will not start,
the action does not fail the job: it clears RUSTC_WRAPPER, so Cargo compiles
uncached, and makes the fallback visible as a warning annotation titled
sccache-fallback, a sccache: FALLBACK (cache disabled for this job) line in
the job summary and the sccache-status output (fallback, or started).
That behaviour is shared-actions #546, and it is the reason both jobs pin
setup-rust at 6cec89ba rather than the estate-wide commit.
Each job's call carries three inputs and one id:
cache-provider: github, because the action is the sole owner of~/.cargo/registryand~/.cargo/gitfor the job;expect-cache, chosen by where the job runs.build-testhas a fork arm on a GitHub-hosted runner, so it takesany.coverage-uploadruns only on Ubicloud and takesubicloud, which fails the job if the proxy is missing;use-sccacheleft at its default oftrue. Setting it tofalsewould leave the job with no cache and no wrapper;id: setup-rust, which the statistics step readssccache-statusandcache-backendthrough.
CARGO_INCREMENTAL: '0' stays at job level because sccache cannot cache an
incremental compilation. Nothing else about sccache is set by the job:
RUSTC_WRAPPER, SCCACHE_GHA_ENABLED and SCCACHE_CONF at job level, an
actions/github-script proxy export, a hand-installed sccache and a hand-run
sccache --zero-stats are all retired, and a contract refuses each. This
repository used to wire them by hand, with use-sccache: 'false', to avoid the
old sccache-action clobbering the proxy export; #523 made setup-rust handle
that, and two owners would start two servers.
The failure is silent and total, which is why it is worth this much text. Three
runs of build-test on the same shape, differing only in the shared-actions
pin and in whether the store had been populated, show both the failure and what
the cache is worth:
| Measure | Before the fix | After, cold | After, warm |
|---|---|---|---|
| Cache location | ghac | ghac | ghac |
| Hit rate | 0.00 % | 33.45 % | 99.79 % |
| Rust hit rate | 0.00 % | 0.19 % | 99.60 % |
| Read errors | 0 | 0 | 0 |
| Write errors | 8170 | 5 | 0 |
| Wall | 25m44s | 39m14s | 16m31s |
The first run had a correct backend, an endpoint and a token both present, and every one of its 8,170 writes failed, so nothing reached the store and nothing in the log said so except the write counter. The second populated the store, which is why it is the slowest. The third reads what the second wrote. The cache is worth about nine minutes a run on this workspace, 16m31s warm against 25m44s for the run that cached nothing at all.
Report. sccache --show-stats runs after the build, printing the backend
setup-rust chose and then the counters, to the log as well as to the job
summary. Cache location reads ghac for the Ubicloud proxy and for GitHub's
own service alike, so the backend line is what tells them apart. The step skips
when sccache-status is not started, since a server that never started has
no statistics; sccache --show-stats would print empty defaults for it, which
reads as a broken integration. The log copy is the one that matters: the
summary cannot be read through the REST API, so it cannot be checked after the
fact. Read the backend line and Cache location on every run, against the
runner. On Ubicloud the backend is ubicloud and Cache location must name
the GitHub Actions backend (ghac); anything else means the proxy was never
selected and nothing is being cached there. On a GitHub-hosted runner, which is
what a fork's pull request gets, setup-rust deliberately selects local disk
under runner.temp and manages its directory cache, so local disk is the
supported result and not a fault. A warm build reporting zero hits is a broken
contract, not a slow one.
Each job also deletes target/llvm-cov-target once coverage has been
generated, printing df -h either side. The instrumented tree has no later
consumer, and on smaller runner shapes a full disk has killed a job silently,
with no error text.
Resource sampling
Both build jobs start a background sampler after checkout that records used memory and used and free disk every 15 seconds, and report peak memory, peak disk and least free disk at the end of the job, to the log as well as the job summary. Disk is sampled alongside memory because disk, not memory, is what has exhausted runners elsewhere in this estate, and it did so with no error text at all.
ubicloud-standard-8 was inherited rather than chosen: it predated this work
and no evidence on this repository argued for it. These are the samples that
replaced the assumption:
| Measure | 8 vCPU, build-test cold | 8 vCPU, build-test warm | 8 vCPU, coverage-upload cold | 4 vCPU, build-test warm |
|---|---|---|---|---|
| Peak used memory | 8,812 MiB | 7,907 MiB | 6,442 MiB | 3,889 MiB |
| Peak used disk | 95,609 MiB | 95,418 MiB | 90,998 MiB | 94,537 MiB |
| Least free disk | 101,691 MiB | 101,882 MiB | 106,302 MiB | 53,166 MiB |
| Wall | 39m14s | 24m38s | 29m17s | 16m47s |
Table 2: Sampled resource use and wall time by runner shape.
Read the constraint per shape rather than as one rule. On eight vCPUs memory
was what decided the sizing: the cold writer's 8,812 MiB is the figure that
ruled out ubicloud-standard-2 at 8 GB. On four vCPUs memory is not close to
binding, at 3,889 to 4,637 MiB against a 12 GB bound, and what bounds the shape
instead is the cargo watchdog and, at a distance, disk. Free disk never fell
below 52 GiB on any run.
Peak memory is a property of the shape, not only of the workload. This is
the part that is easy to get wrong. Cargo scales its parallelism with the
processor count, so fewer processors means fewer concurrent rustc processes
and a lower peak: the same job that peaked at 7,907 MiB on eight vCPUs peaked
at 3,889 MiB on four. A memory peak measured on one shape cannot be carried
across to another as though it were fixed. Measure the target shape.
The cold writer sets the floor, not the warm run, because it is the run
that has to succeed. Taking the two rules together, the eight-vCPU cold peak of
8,812 MiB is what ruled out ubicloud-standard-2 at 8 GB while the estate was
still on eight vCPUs; on four, the headroom is far wider than that comparison
suggests.
The cold limb on four vCPUs is inferred, not measured. Every four-vCPU run so far has been warm, because the shape change did not alter any sccache key, so the run intended as the cold writer found the store already populated and returned a 100 % hit rate. Every four-vCPU peak observed lies between 3,889 and 4,637 MiB, and reduced parallelism means a genuinely cold run should stay below its eight-vCPU counterpart of 8,812 MiB, so the 12 GB bound looks safe by a wide margin. That is inference, and it needs a particular kind of run to settle it, rather than merely the passage of time. A dependency bump invalidates only the objects it touches, and eviction removes only what it happens to reach, so either can leave a partly warm build whose peak says nothing about a cold one. What settles it is a run that reports a zero or near-zero hit rate, whether that arrives by a wide enough invalidation or is produced deliberately. Until such a run exists, treat the cold figure as unconfirmed rather than as evidence.
Of the two constraints that do bind the four-vCPU shape, the watchdog is the
one that had to move first. RUN_RUST_CARGO_WAIT_TIMEOUT caps a single cargo
command rather than the job, and had to rise from 1,800 s before the shape
could shrink: the eight-vCPU cold writer already ran 1,757 s, so a slower shape
would have had cargo killed before either job timeout noticed, and the casualty
would have been the cold writer on main. Disk is the other, at a distance: it
fell to 52 GiB against 99 GiB on the larger shape, still ample, but the only
measure to move materially.
Keep the samplers. The shape is only defensible while it is measured, and the next person to question it needs the same evidence this change rested on.
ci.yml accepts workflow_dispatch so a warm run can be measured on demand. A
dispatch restores what a pull request restores and writes nothing:
coverage-main.yml is the only job that saves on this repository.
One download remains deliberately uncached. The cs-coverage CLI is fetched on
every run because upload-codescene-coverage only caches it when cli-version
is pinned, and its cache step uses an unpinned actions/cache@v4 that this
repository cannot pin from here.
The uv tool layers are an exception to the trunk-writer rule rather than to the ownership rule. They are cached by the pull-request job that installs them, which is also the only job that installs them, so there is no trunk job to designate as the sole writer instead.
Who may publish coverage
coverage-main.yml owns the CodeScene upload and the ratchet baseline. It
answers a push to main and nothing else, which the contract holds by
equality. The upload's condition also carries github.ref == 'refs/heads/main'
as a whole conjunct, split on &&, with any unquoted || refused, so a
dispatch trigger added later cannot reopen a branch upload. No workflow a pull
request can reach may call the CodeScene action, run cs-coverage, name
codescene.io, forward every secret, or carry the credential. The rule is
CV-005, and its reason is availability: a pull request must not turn red
because an external service or a token did.
make test-workflow-contracts holds the rule by running
cv005-contracts check, the shared contract library in leynos/shared-actions
(packages/cv005-contracts), from a full commit named by CV005_CONTRACTS_REF
in the Makefile; CI runs it in a "Check the CV-005 contracts" step. A fix to
the rules is therefore a pin bump. The target needs uv, which fetches the
Python 3.13 the library runs under. The repository's only parameter is
repository in .github/cv005.toml. The library's own suite proves each rule
refuses the shape it exists to refuse, so this repository keeps no copy of the
readers or the refusal cases. Workflows are read strictly: a duplicate key, or
a workflow declaring both a quoted and an unquoted on key, is refused rather
than silently resolved, and a reading failure exits 2 rather than passing.
The credential is bound in no env on the publisher. The upload action is
composite and hands its step's env to the upload-artifact and cache steps
nested inside it, so access-token is passed straight from
${{ secrets.CS_ACCESS_TOKEN }}. Whether the credential exists is answered by
a step whose sole command, with no if: and no env, is
echo "available=${{ secrets.CS_ACCESS_TOKEN != '' }}" >> "$GITHUB_OUTPUT",
and the upload's condition requires that output. The publisher's concurrency is
exactly group: coverage-main-${{ github.ref }} with
cancel-in-progress: false, so runs never overlap and a newer trigger replaces
an older pending run. No commit order is promised beyond that, because GitHub
does not promise to start runs in the order they were triggered. A manual
"Re-run jobs" keeps its run_id, so it republishes that commit's coverage but
replaces no ratchet baseline, which is saved under a run-keyed cache key.
Two gaps are known and accepted. A Dependabot automerge made with
GITHUB_TOKEN fires no push, so that merge publishes nothing until the next
push to main. And should the publisher gain a dispatch trigger, a dispatch
that replaced a pending push would upload the same or a newer commit, but the
ratchet baseline is saved only on a push, so it would stay one commit behind
until the next one.
One test execution per pull request
The instrumented coverage run is the only test execution on Linux. It uses
all-features, all-targets, and doctests, so it covers everything the
former separate cargo test step covered and more, for one compile rather than
two. A workflow contract in tests/workflow_contracts.rs fails if a second
cargo test or cargo nextest step reappears in either job.
Workflow contracts
tests/workflow_contracts.rs asserts the rules above. It is a harness rather
than a test file: the rules live in thirteen modules under tests/contracts/,
split by the question each asks.
| Module | Asks |
|---|---|
supply_chain.rs |
What will the estate execute? Pinned cache and shared-action references, no source-built tools, prebuilt Whitaker, and the setup-rust revision that owns the compiler cache. |
placement.rs |
What does it cost, and who owns each cache? Runner placement and labels, bounded timeouts, one owner per cached path, an installer before the first use of what it installs, a single test execution per build job, the uv cache key. |
fork_fallback.rs |
Can a fork's pull request start the lane it reaches? The pull-request lane falls back to ubuntu-latest for forks through the one prescribed expression, lanes no fork reaches name their runner outright, and no runs-on declaration carries a line break. |
execution_control.rs |
Do the readers of if and continue-on-error tell a constant from a condition that depends on the event, at job and step scope? The rule that consumes them lives in placement.rs; these drive the readers with shapes the workflows do not contain. |
concurrency.rs |
Which runs may a newer push cancel? Every workflow a pull request starts declares a group keyed on the pull request, falling back to the run id, and cancels only on the pull_request event. |
codescene_uploader.rs |
Does the coverage uploader carry its approved commit and none of the inputs it now rejects? No installer-checksum, no CODESCENE_CLI_SHA256, and no checksum-refresh dispatch in either extension. |
compiler_cache.rs |
Is sccache actually working? setup-rust owns it with the inputs and id the report reads, no hand-rolled wrapper, export, install or start survives beside it, it precedes the build and the report follows, and the resource sampler with its report. |
sampler_reading.rs |
Does a line in a run script actually run? The quoting, comment, escape, and guard reading in tests/support/shell_reading.rs, which compiler_cache.rs asks its sampling question through, driven with shapes the workflows do not contain. |
parsing.rs |
Does the loader read workflows correctly? Its subject is the loader, not any workflow in this repository. |
step_fields.rs |
Does the loader keep a step's id and env as written? Present and absent values, an empty value kept apart from an absent one, Step::env_value, and each malformed env refused for its own reason. |
statistics_report.rs |
What does the compiler-cache report actually do? Each build job's statistics step is run under bash against a stand-in sccache for a started server, a fallback, an empty status and a missing binary, and its rendered log and summary are snapshotted. Unix only. |
timeouts.rs |
Which timer ends a run first? The coverage action's cargo watchdog set explicitly and by value, each coverage job's ceiling above that watchdog plus the measured work around it and equal to the documented 120 minutes, and the two nextest tiers absent rather than silently enabled. |
timeout_budgets.rs |
Do the readings that ordering rests on say what they claim? The coordinate match, the ceiling predicate, and the two conversions, driven with values chosen to separate a correct reading from a plausible wrong one. |
Table: the thirteen contract modules, and the question each one asks of the estate.
Each module also pins the inputs that make its rules true, so a workflow cannot
keep the shape of the policy while dropping its substance: cache-provider,
use-sccache, the Whitaker installer version, the coverage flags, and the uv
cache paths and key.
The split is not only about the 400-line limit. parsing.rs reads a different
subject from the other modules, and separating it makes that visible: a failure
there means the loader is wrong, not that a workflow is.
tests/support/workflow_model.rs holds the job and step types the properties
and the contracts share. tests/support/runner_selection.rs holds the
runs-on shapes, a label, a label list, a group and the fork-fallback
expression, with the label predicates the placement rules ask through, and
tests/support/placement_expression.rs holds the one expression grammar that
reads a fork-fallback declaration into its guard and two arms, refusing every
other spelling rather than repairing it; tests/support/workflow_estate.rs
holds the pinned commits, the whole-file Workflow type, and the errors
parsing reports, which only the contracts need.
tests/support/workflow_loader.rs turns workflow files into those values;
tests/support/workflow_texts.rs reads the same files as raw text, through the
same capability and file listing, for contracts that must see what the parser
drops; and tests/support/workflow_config.rs reads the other repository files
a contract needs, currently actionlint's runner registration.
tests/support/shell_reading.rs holds the bounded shell reading that separates
a command from text that merely spells one; it sits in support because
compiler_cache.rs asks a question through it while sampler_reading.rs asks
a question about it. They are separate because the subject differs: a failure
in one is a workflow that would not parse, in the other a configuration file
that could not be read.
Parsing is strict about shape and permissive about spelling. A field that is
present but of the wrong type is an error rather than a silent default, because
a contract that read an empty string for a mistyped runs-on would pass a
workflow it should reject. The exclusive shapes GitHub Actions enforces are
enforced here too: a step sets uses or run, never both, and a job either
calls a reusable workflow or names a runner and runs its own steps, never both.
Accepting a mixture would let the contracts reason about a job the runner would
never schedule.
Against that, every form the platform genuinely accepts must parse. runs-on
may be a label, a list of labels, or a mapping naming a runner group, and on
may be an event, a list, or a mapping, read under the bare key that YAML 1.1
turns into the boolean true. The files are read through a cap_std directory
capability rooted at .github/workflows.
Three matching rules exist because a loose match quietly defeats the rule it is part of.
- Action references are compared on the whole coordinate before the
@, publisher included. A suffix match would letuntrusted/setup-rustsatisfy a rule written about the sharedsetup-rust. - A step owns a cache only when its
usesnamesactions/cache,actions/cache/restore, oractions/cache/saveexactly.actions/cache-auditshares the prefix, caches nothing, and would otherwise contribute an invented claim on whateverpathinput it carried. - Runner labels are compared against the parsed
self-hosted-runner.labelslist in.github/actionlint.yaml, by equality. Searching the file as text would acceptstandard-8becauseubicloud-standard-8contains it, and would accept a label that appears only in a comment.
Two assurance methods are used together, following
ADR 003. The contract
modules hold bounded rstest cases over the workflow files as they stand, and
tests/workflow_model_properties.rs samples the wider domain with proptest:
arbitrary step orderings, repeated display names, interleaved unrelated steps,
actions that merely share the actions/cache prefix, and split caches whose
halves agree or disagree on a key, or where a third step claims a paired key.
The properties check cache-owner uniqueness and installer-ordering against
small oracles written independently of the implementation.
A contract that requires a command to run reads each line and refuses the
disabling forms rather than searching the whole run value:
if false; then free -m; fi satisfies a substring search while sampling
nothing, and free -m || true runs but discards its verdict. The refusal is
narrow on purpose, because these samplers legitimately use pipes and command
substitution, so only the disabling forms are rejected rather than every line
that is more than a bare command.
That reading works on shell words, not on letters, and it is the words that
make it hold. The spacing between an operator and its operand is the shell's to
choose, so free -m || true discards the verdict exactly as the one-space
spelling does and a search for the text || true misses it. A guard's depth
counts the words if and fi, because find in a guard's body contains the
letters fi and would otherwise close the guard and expose the lines beneath
it as though they ran. A backslash makes the next character literal outside
single quotes, so echo "\$(free -m)" prints the text and samples nothing,
where the unescaped spelling beside it does sample.
Contracts that forbid a command, such as the single-test-execution and
no-source-build rules, keep the substring search: wrapping a prohibited command
leaves its text in place, so the wrap makes those stricter rather than weaker.
timeout_budgets.rs follows the same split for the ceiling arithmetic: bounded
cases at the boundary, and a proptest property over the whole u64 domain,
since both a watchdog and a timeout-minutes are parsed from a workflow file
and a value near the maximum is reachable by editing one. Both conversions
saturate rather than wrap, so such a value stays preposterous instead of
becoming a small number that fails the ordering for the wrong reason. Both are
also const fn, which a call from a test body cannot demonstrate, so the
module binds the documented pair to constants: dropping either const fails
the build rather than a test. Run both with make test, and run actionlint
after editing any workflow.
Only one restore and one save sharing a key count as a single owner. Two restores on the same key are two owners, and so are a matching pair plus a third step, because otherwise a genuine duplicate could hide behind the split-cache exception.
Cancelling superseded pull-request runs
Every push to a pull request starts a fresh run of each gate. The run already in flight is answering a question about a commit nobody will merge, and left alone it holds a runner until it finishes, so the branch pays twice for one answer. Every workflow a pull request can start therefore carries a concurrency block:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Two halves matter, and each fails in a way nothing else would notice.
- The group keys on the pull request. A group built from
github.run_idis unique to one run, so it matches no predecessor and cancels nothing while reading exactly like a concurrency control. A constant group is the opposite failure: every open pull request shares one queue, and the first push anywhere cancels the gates running everywhere else. The run id has one legitimate place, the fallback arm after the pull request number: only a push or a dispatch reaches it, and such a run has no predecessor to cancel. Falling back togithub.refinstead would put every push to a branch, and every dispatch, in one queue, where a third trigger replaces a pending second run that was meant to complete. - Cancellation is conditioned on the event. A literal
cancel-in-progress: truereads as the stricter setting and is a regression. A push tomain, a schedule, and a dispatch have no successor waiting, and the run onmainwrites the warm cache and records the coverage that no later run repeats.
pull_request_target workflows are out of scope. They run against the base
repository to carry a token, and the ones here automate pull-request
housekeeping rather than building, so cancelling one mid-flight is a hazard
with no minutes to win.
The cancellation contract
tests/contracts/concurrency.rs, in the workflow_contracts harness, reads
.github/workflows and asserts, for every workflow declaring a pull_request
trigger, that it declares a concurrency group, that the group names no per-run
expression outside its fallback arm, that the fallback arm is the run id, that
the group names something that varies per pull request, and that
cancel-in-progress is exactly the expression above. A further test asserts
that discovery still finds the workflows it is expected to, so a broken read
cannot empty the list and turn the rest into a vacuous pass. The loader records
the two values as written rather than interpreting them, because a literal
true coerced to a boolean could not be told apart from the expression. Run
the harness with make test.
Test timeouts: the two tiers this repository has
Four independent timers can end a test run, and the canonical statement of how
they must be ordered lives in the generate-coverage README in
leynos/shared-actions. Two of the four exist here.
| Tier | What it bounds | Where it is set | Current value |
|---|---|---|---|
Per-test slow-timeout |
one test | nextest, not used here | absent |
nextest global-timeout |
the whole test run | nextest, not used here | absent |
| Cargo watchdog | one cargo invocation, wall clock |
RUN_RUST_CARGO_WAIT_TIMEOUT at job level in ci.yml and coverage-main.yml |
3,600 s (60 m) |
Job timeout-minutes |
the whole job | job level | 120 m in both ci.yml and coverage-main.yml |
Table: the timers that can end a run, innermost first.
The two nextest tiers are absent by construction rather than by omission. The
coverage step passes use-cargo-nextest: 'false', so the instrumented run is
cargo llvm-cov over plain cargo test, and there is no
.config/nextest.toml for anyone to have set a per-test or whole-run budget
in. Turning nextest on would introduce both tiers at once, unbounded,
underneath a watchdog sized for neither, so the contract fails if the input
changes and the guide has to change with it.
The watchdog is the tier nobody expects
It belongs to the shared generate-coverage action, which wraps the cargo
invocation and kills it after a wall-clock budget. It defaults to 1,800 s, and
nothing in this repository would mention it if a job stopped setting the
variable. That default is not far above the work: the coverage step has already
taken 1,728 s on run 33940327512.
When it fires the step prints:
::error::cargo did not exit within 3600s; killing. This is a budget, not a
detected hang: raise the cargo-wait-timeout input, or
RUN_RUST_CARGO_WAIT_TIMEOUT, if the build is legitimately slower. A cold
sccache store makes the first run on a branch compile everything inside this
budget.
Take the message at its word. Nothing was detected as hung. A budget expired, and on a cold compiler cache that is the expected outcome rather than a symptom.
The clocks do not start together
The job timer starts when the job starts, before the checkout, the toolchain
setup and the cache restore, and it is still running through whatever follows
the coverage step. The watchdog starts when cargo does. So a ceiling merely
above the watchdog still cancels the job before the watchdog can report an
overrun, and a cancellation discards the log that would have explained it.
The ceiling is therefore sized as the watchdog plus the work outside its window, measured from the worst of several runs rather than one:
| Lane | Worst coverage step | Worst whole job | Outside the step | Run |
|---|---|---|---|---|
ci.yml build-test |
1,702 s | 2,354 s | 859 s | 33830336409 |
coverage-main.yml coverage-upload |
1,728 s | 1,775 s | 286 s | 31892219565 |
Table: measured coverage-step and whole-job durations, read across fifteen
successful ci.yml runs and twenty of coverage-main.yml.
The widest gap is 859 s, so the contract allows 15 minutes. That makes the requirement 3,600 s + 900 s = 75 minutes, and both lanes are set to 120, which is 45 minutes above it.
That allowance was not enough for every four-vCPU run. PR #315's required
build-test run
38434329511 ran for
90 minutes and ended cancelled while the coverage command was still compiling
bevy. The runner reported 3,827 MiB peak memory, 63,427 MiB least free disk,
and no sccache timeouts. Both coverage jobs now allow 120 minutes, adding 30
minutes while remaining well below GitHub's six-hour default.
coverage-main.yml previously moved from 75 minutes, exactly its requirement,
to 90 minutes. The 75-minute value passed the ordering contract but left no
slack: a cold run spending the full watchdog could be cancelled with budget
left, and the cancellation would discard the log explaining it. Its 120-minute
ceiling now matches the pull-request lane.
tests/contracts/timeouts.rs asserts all of this by value over every job that
invokes the coverage action, in both workflows. It requires the watchdog to be
set explicitly rather than inherited, requires it to parse as whole seconds,
requires each job's ceiling to clear the watchdog plus the allowance, and fails
if a job declares no ceiling at all, since that would silently mean GitHub's
six-hour default.
It pins the two documented values as well as ordering them: the 3,600-second watchdog and the 120-minute ceiling. The derived check accepts any ceiling at or above 75 minutes, so on its own it would let either value drift away from this section without failing anything. Pinning them makes the guide and the workflows one statement.
The coverage action is matched by its whole path, taking the part of uses
before the @ rather than testing the coordinate as a prefix. Every workflow
here pins the action itself, so a prefix test passes on this tree while also
claiming a sibling action whose name merely begins with the same text, and that
sibling has no watchdog for these assertions to be about. A near-miss case
covers it.
The three-way ceiling decision is a named predicate rather than a branch inside the diagnostic. Both lanes sit forty-five minutes above their requirement, so the assertion over the workflows cannot tell a predicate that compares correctly from one that ignores the allowance entirely; the predicate is therefore driven directly with a ceiling on its requirement, one second below it, and none at all.