Continuous integration

Updated Oct 13, 2026

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/registry and ~/.cargo/git for the job;
  • expect-cache, chosen by where the job runs. build-test has a fork arm on a GitHub-hosted runner, so it takes any. coverage-upload runs only on Ubicloud and takes ubicloud, which fails the job if the proxy is missing;
  • use-sccache left at its default of true. Setting it to false would leave the job with no cache and no wrapper;
  • id: setup-rust, which the statistics step reads sccache-status and cache-backend through.

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 let untrusted/setup-rust satisfy a rule written about the shared setup-rust.
  • A step owns a cache only when its uses names actions/cache, actions/cache/restore, or actions/cache/save exactly. actions/cache-audit shares the prefix, caches nothing, and would otherwise contribute an invented claim on whatever path input it carried.
  • Runner labels are compared against the parsed self-hosted-runner.labels list in .github/actionlint.yaml, by equality. Searching the file as text would accept standard-8 because ubicloud-standard-8 contains 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_id is 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 to github.ref instead 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: true reads as the stricter setting and is a regression. A push to main, a schedule, and a dispatch have no successor waiting, and the run on main writes 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.