Skip to content

👷 ci: spend a pull request run on its critical path - #2252

Merged
gaborbernat merged 2 commits into
mainfrom
ci/fast-ci
Sep 9, 2026
Merged

👷 ci: spend a pull request run on its critical path#2252
gaborbernat merged 2 commits into
mainfrom
ci/fast-ci

Conversation

@gaborbernat

Copy link
Copy Markdown
Member

A green pull request run takes about 20 minutes, and the audit in #2251 says where they go. coverage sets the critical path at 17.4 m. frontend takes 10.1 m because coverage-frontend builds under .tox/coverage-target/frontend, outside the target/ that rust-cache saves, so it compiles 1,241 units from nothing on every run. The Cargo cache holds 10,239 MB against GitHub's 10 GB limit, and 3.3 GB of that is duplicates: entries the conformance and mise steps wrote from pull request refs, and a second copy of every key after the runner image's stable toolchain moved from 1.98.0 to 1.98.1, since rust-cache hashes each installed toolchain. The evictions that follow are what left contracts without an entry (#2244, fixed in #2245). Every merge to main cancelled the run before it, so a merge burst wrote no caches and reached no verdict. The CodSpeed serving leg spent 7.9 m rebuilding behind an entry the parsing leg had written first. Read the Docs compiled the whole peryx binary on a two-job builder for one openapi call, 440 to 490 s per build, and hit its 15-minute limit on #2239. 📏

The setup action now writes its tool and Cargo caches from main only, the conformance workflow gets the same cache-save-if, and the action uninstalls the toolchains the runner image ships before rust-cache computes its key, so the key hashes only the pinned one and an image update stops rotating every entry. Push runs group by commit and only pull request runs cancel each other. The frontend job roots CARGO_TARGET_DIR at target/, which puts target/frontend where rust-cache already looks. The serving benchmark leg is the one allowed to write the shared CodSpeed entry, because it builds the superset the other two legs need. The site serves a committed site/static/openapi.json, just docs regenerates the document and fails when the two differ, just openapi refreshes it, and .readthedocs.yaml drops the Rust tool and the cargo step; two runs of peryx openapi on the same tree are byte-identical, so the check is stable. 🧭

A changes job with dorny/paths-filter stands contracts, coverage, frontend and platform down for a pull request that touches only site/**, Markdown, .readthedocs.yaml, renovate.json or LICENSE. Crates compile three site files in through include_str! (site/content/ecosystems/oci/reference/policy.md, site/static/icon.svg, site/static/mark.svg), so an embedded filter names them and just _embedded-docs-contract, now part of lint-automation, fails whenever a crate embeds a site file the filter does not list. CodSpeed skips its benchmarks for a pull request that changes only tests or docs. Both gates accept skipped for those jobs and nothing else, and pushes and dispatches run everything as before.

Expected after this lands: frontend about 4.5 m, PyPI serving benchmarks about 4 m, the Read the Docs build about 90 s, and a docs-only pull request done in about 4 m. coverage is untouched, so a pull request run stays coverage-bound at about 18 m; splitting it is the next item in #2251. A fully cold run is now bounded by contracts at 5.3 m plus coverage. The changes job adds about 15 s before the Rust jobs start. PR #2162 also edits ci-gate, so whichever lands second rebases the gate condition.

Closes #2251

@gaborbernat gaborbernat added the enhancement New feature or request label Sep 9, 2026
gaborbernat added a commit that referenced this pull request Sep 9, 2026
Read the Docs rejects a configuration with neither `build.tools` nor
`build.commands`, and dropping the Rust toolchain in #2252 left it with
neither: build 34467122 stopped after `cat .readthedocs.yaml` with "At least
one of the following configuration options is required". Node is what the
build already uses for pagefind and the cheapest tool the image ships, so it
stands in for the compiler the build no longer needs.
@codspeed-hq

codspeed-hq Bot commented Sep 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 29 untouched benchmarks
⏩ 133 skipped benchmarks1


Comparing ci/fast-ci (3a8fc32) with main (4ad3c7a)

Open in CodSpeed

Footnotes

  1. 133 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

A green pull request run takes about 20 minutes, and the audit in #2251
shows where they go. `coverage` sets the critical path at 17.4 m. `frontend`
takes 10.1 m because its target directory sits outside the cached `target/`,
so it compiles 1,241 units from nothing on every run. The Cargo cache holds
10,239 MB against a 10 GB limit, 3.3 GB of it duplicates written from pull
request refs or keyed on the runner image's own toolchain, and the evictions
that follow are what left `contracts` cold. Every merge to main cancelled the
run before it, so main wrote no caches and got no verdict. The CodSpeed
serving leg rebuilt for 7.9 m behind a cache the parsing leg had written
first. Read the Docs compiled the whole `peryx` binary on a two-job builder
for one `openapi` call, 440 to 490 s, and hit its 15-minute limit on #2239.

Caches now come from main alone, including the conformance and mise ones,
and the rust-cache key hashes only the pinned toolchain so an image update
stops rotating every entry. Push runs group by commit so a merge burst keeps
its verdict and its cache writes. The frontend build roots under `target/`
where rust-cache picks it up. The serving leg is the one that writes the
benchmarks cache, since it builds the superset. The site serves a committed
`site/static/openapi.json` that `just docs` checks against the binary, and
Read the Docs drops Rust. A `changes` filter stands the Rust jobs down for a
pull request that touches only the site or Markdown, and skips benchmarks
for one that touches only tests; a justfile contract keeps the list of site
files a crate embeds current so that filter cannot hide a real change.

Closes #2251
Read the Docs rejects a configuration with neither `build.tools` nor
`build.commands`, and dropping the Rust toolchain in #2252 left it with
neither: build 34467122 stopped after `cat .readthedocs.yaml` with "At least
one of the following configuration options is required". Node is what the
build already uses for pagefind and the cheapest tool the image ships, so it
stands in for the compiler the build no longer needs.
@read-the-docs-community

Copy link
Copy Markdown

Documentation build overview

📚 peryx | 🛠️ Build #34468050 | 📁 Comparing 3a8fc32 against latest (0f60ae1)

  🔍 Preview build  

1 file changed
± contributing/ci/index.html

@gaborbernat
gaborbernat merged commit a7dab14 into main Sep 9, 2026
22 checks passed
@gaborbernat
gaborbernat deleted the ci/fast-ci branch September 9, 2026 10:16
gaborbernat added a commit that referenced this pull request Sep 9, 2026
The site serves a committed copy of the document since #2252, and `just
docs` fails when it differs from what the binary produces. Renaming the
trash record's `name` and `reference` to the `resource` and `artifact` the
handler actually reads, and giving the schema endpoint the example the
document-wide check requires, both changed the generated output.

Rebuilt through `just openapi`. Those two edits are the whole diff.
gaborbernat added a commit that referenced this pull request Sep 9, 2026
* 🧪 test(backup): pin the flags a backup opens with

The open flags carry the guarantees: a member is created fresh rather
than reused, is never reached through a symlink, and no descriptor the
backup holds survives into a child process. Almost none of that was held
to anything.

Existing tests cover five of the ways the set can be broken, all through
`CREATE` and `EXCL`, because dropping either makes a backup fail
outright. The rest were free. `EXCL` already refuses an existing path,
symlink or not, so the tests that plant a symlink pass whether or not
`NOFOLLOW` is set, and nothing looked at `CLOEXEC` at all. `open_dir` was
unheld in every respect: it would open a regular file as the backup root,
follow a symlink to somewhere the operator never named, and hand the
descriptor to any child it spawned.

The descriptor assertions compare the whole flag set rather than testing
for membership, since a descriptor carrying `CLOEXEC` alongside something
else is not the guarantee being claimed.

* ♻️ refactor(backup): name the two open flag sets

Three call sites spelled out flag lists, two of them the identical four
flags. Naming each set says what it is once, and puts the reason beside
it rather than leaving a reader to infer it from four constants.

Spelling a fixed set as a bit operation also invites the wrong operator.
`^` produces the same value as `|` here, and only because every flag is a
disjoint bit; a flag that later overlapped an existing one would make the
two diverge with nothing to notice it. `union` names the operation the
set actually wants.

* 🐛 fix(api): document trash with its wire names

The trash record example and the inspect query in the OpenAPI document
named the resource `name` and the artifact `reference`, while the handler
writes `resource` and `artifact` and reads the same names from the query.
A client built from the document sent the wrong parameter and could not
find any record. The docs site repeated the same names in its schema table.

The document now uses the handler's names, and a test pins both the record
example keys and the inspect query parameters to that set, so the two
cannot drift apart again without the suite saying so.

* 🧪 test(peryx): close the app and config survivors

A file-scoped mutation run over app/cache.rs, app/config.rs and config/raw.rs
left four mutants alive, each because the suite never asked the question the
mutated line answers.

The fsck and repair fixture driver ignored the indexes it was handed, so a
command that resolved no indexes at all produced identical output. It now
echoes their names, which makes the resolved index set part of every fsck and
repair assertion. The cache listing fixture only filtered by an age of one
second, which a placeholder clock of one satisfies; a threshold of decades
passes only against the real clock, because the gadget page was fetched at the
epoch. The config check only ever printed a plural index count, and the
prefetch table was checked on the raw index but never on the resolved one.
Each gap has a case now, confirmed by rerunning the same mutants to zero
missed.

* 🧪 test(api): pin the shape of every documented operation

A file-scoped mutation run over the OpenAPI builders showed that an
operation could lose its summary, tag, responses or request body, and a
shared example record could turn into null, without any test noticing.
The existing checks read specific routes for specific facts, so a builder
emptied wholesale slipped between them.

One document-wide test now requires every operation to carry a tag, a
summary and at least one described response, every JSON body to carry an
example or a schema, and every request body to carry content. Binary
bodies are exempt because an octet stream has no example worth printing.
The schema endpoint gained the example this rule asks of it. The shared
records that nest inside larger examples, the analytics window, the quota
meters and the retention candidate, are pinned by their field sets, since
a document-wide rule cannot tell a real nested record from a null.

* 🧪 test(peryx): close the config and operator survivors

Four mutants from the 2026-08-30 inventory were still alive in config and
operator code, each on a boundary the suite only ever drove from one side.

A secret file was tested one byte over its limit and never at it, so the
inclusive comparison could become exclusive unnoticed. The TLS debug form
was never printed with only one half of a client identity, which is the
case where "either half counts" differs from "both halves". A restore was
tested rolling back and moving forward but never onto a target at exactly
the backup's serial, where a rollback warning would be wrong. And no backup
round trip carried index settings, so a snapshot that dropped them
restored to an equal config. Each has its case now, and each was confirmed
by applying the mutation and watching the new case fail.

The archive copy buffer is spelled as a literal rather than as an
arithmetic expression. No test can observe the buffer's size, so an
operator there carries no meaning a mutation could contradict.

* 🧪 test(config): drive every operand of the merge predicates

The 2026-08-30 inventory listed the `||` chains in index, upstream, OIDC,
LDAP and webhook classification as surviving mutation, and reading the
tables showed why: each chain was driven through one operand. Routing
options were refused only for `fallback`, refresh controls only for
`credential_refresh_secs`, direct-bind LDAP only for `bind_dn`, and the
issuer check only for user info and case. Turning any `||` into `&&` left
the tested operand's verdict unchanged.

Each table now carries one case per operand, so any single operand refuses
on its own. The webhook guards gained the empty-string cases they exist
for: an empty `secret` or `secret_env` is neither a literal nor a variable
name, and the guard that says so was never exercised. Every mutant was
applied by column and watched fail against the new case before the case
was kept.

* 🧪 test(peryx): close the model and restore survivors

A file-scoped mutation run over config/model.rs, operator/restore.rs and
operator/snapshot.rs (164 mutants) left two alive.

The local-member lookup in the scheduled-job check could match any node
other than this one and no test would notice, because every roster in the
suite contained the identity. A roster that names other nodes but not this
one now expects the schedule's own refusal, which the mutated lookup turns
into a later topology error instead.

The metadata and config copy check on restore could accept a member that
changed between verification and copy. The blob variant of that check had
its own cases; the member variant now has the same two, one per field.

* 🧪 test(config): accept a replica page at the primary limit

A file-scoped mutation run over config/merge.rs (159 mutants) left one
alive: the replica page-size bound was driven from one side only. A page
one past the primary limit was refused, but no test asked for exactly the
limit, so the inclusive comparison could turn exclusive unnoticed.

The runtime-bounds case now runs at the limit as well as below it, with the
bound named through the primary's own constant rather than a copied number.

* 🧪 test(api): evaluate the document checks unconditionally

The coverage gate rejected the document-wide OpenAPI check: it recorded
each violation inside an `if`, so on a document that satisfies every rule
the six recording lines never ran and the gate counted them uncovered.

Each rule now produces a label and a verdict on every operation, and the
violations are the labels whose verdict says so. Every line runs whatever
the document holds, and the assertion is unchanged: the list of operations
with missing pieces must be empty.

* 🧪 test(operator): pin what a backup verification counts

A file-scoped mutation run over operator/verify.rs (111 mutants) left five
alive, all for the same reason: the tests asserted that a report contained
the line they cared about and never what else it held. Under a mutated
problem counter the expected line still appears, so a `contains` check
agrees with a broken count.

Each report is now pinned by its problem total, which makes the count part
of every assertion: a tampered blob is two problems and a missing one is
one, a duplicate index row is three, and a metadata mismatch is two lines
and nothing from the scans an opened store would have run. The placement
count needed a case rather than an assertion, because no test put a
placement row in a single-node store, where the mode check makes the
backup and its verification agree on counting none.

* 📝 docs(api): regenerate the committed OpenAPI document

The site serves a committed copy of the document since #2252, and `just
docs` fails when it differs from what the binary produces. Renaming the
trash record's `name` and `reference` to the `resource` and `artifact` the
handler actually reads, and giving the schema endpoint the example the
document-wide check requires, both changed the generated output.

Rebuilt through `just openapi`. Those two edits are the whole diff.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make a pull request run fit its critical path, not its cache misses

1 participant