Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Sheltie on real repositories

We measured what Sheltie would have done on 23 popular open-source repositories over 90 days (landings from 2026-07-21 to 2026-10-19). We compared it with what each project does today, not with a strawman. This page reports what we found, including where Sheltie does not help, where it would have let breakage through, the bugs in Sheltie the study exposed, and what we could not prove.

Every footprint here is the exact one Sheltie’s adapters compute for that commit. The queue results come from replaying each project’s real arrival times through Sheltie’s scheduler. They are simulations, not production runs.

The scripts, the bench and every number are in research/real-world.

Download this report as a PDF

47–75%less waiting in the merge queue, on five GitHub merge-queue projects (like for like)
72–85%fewer CI runs where the queue runs the same CI as pull requests

Summary

Where projects use a merge queue, Sheltie would cut waiting substantially. For pull requests the real queue never ejected, total time from entering the queue to landing fell by 47–75% on five GitHub merge-queue projects (rust-analyzer, junit, trino, bevy, gradle). Where the queue runs the same CI as the pull request (rust-analyzer, junit, trino), Sheltie also needed 72–85% fewer CI runs than the queue’s builds.

Most of that comes from landing a change without a new build when nothing that landed since its CI touches what it reads. That is only as safe as the pull request’s CI. Three of the projects (bevy, gradle, rust-lang/rust) run more in their queue than on pull requests. For them, the fair comparison makes Sheltie run their full CI on every pull request, and the CI saving disappears: Sheltie would need about as many runs as their queue builds today.

Queue failures happen either way; a few would have reached main. On the five GitHub merge-queue projects, 223 pull requests that Sheltie would have landed at once failed a queue build at least once. 165 of them went back into the queue with no new commits and merged: the failure was most likely a flaky test or another pull request in the same build, and the same reruns would likely happen with Sheltie. 18 only rebased. In 40 the pull request’s own diff changed after the failure:

  • 14 failed checks that run only in the queue: 10 repository-wide checks at gradle and 4 jobs at bevy;
  • 14 more at gradle followed failures in its fuller queue CI. We did not identify the failing tests, so “the test only ran in the queue” is likely but not confirmed;
  • 2 changed CI workflows;
  • 1 was a semantic conflict with main that footprints missed;
  • for 9 we could not determine the cause.

That 1 and those 9, and possibly some of the 14 unconfirmed gradle cases, are where Sheltie’s footprints may have missed a real interaction.

On projects without a merge queue, we could not show that Sheltie prevents red main. main went red and stayed red (two or more consecutive failed runs) 176 times across 13 projects. An overlapping landing was in range in 83 of those episodes; chance alone predicts 86. Stale merges are common (21–90% of pull requests merge after something else landed), but they do not explain the breakage we can see. In these projects Sheltie would add CI: about 89 days of CI wall time over 90 days across the 14 projects whose CI time we have, as an upper bound.

The study also found problems in Sheltie, fixed before these results were computed (see What the study changed in Sheltie).

The projects and how they merge

We classified 26 projects from their GitHub rulesets, Actions events and pull request timelines. Three have no GitHub pull request merge flow to compare against:

  • golang/go reviews on Gerrit;
  • guava is exported from Google’s internal repository;
  • spring-boot’s maintainers merge locally.
GroupProjects
GitHub merge queuerust-analyzer, junit-framework, trino, bevy, gradle (CI on TeamCity)
borsrust-lang/rust
Prow/Tidekubernetes
Serial label bottraefik (the bot rebases a labelled pull request and merges after CI)
Plain merges, no queueruff, tokio, nushell, prometheus, hugo, vite, vitest, storybook, vscode, home-assistant, fastapi, pydantic, cpython, keycloak, okhttp

Whether the queue runs more than pull request CI decides how to read the results:

ProjectQueueQueue CI vs PR CIEvidence
rust-analyzerGitHub merge queuesamesame workflows on pull_request and merge_group, no merge_group-only jobs
junit-frameworkGitHub merge queuesamesame workflows on pull_request and merge_group, no merge_group-only jobs
trinoGitHub merge queuesameci.yml: merge_group differs only by a checkout step for path filtering
bevyGitHub merge queueheaviervalidation-jobs.yml: jobs with if: github.event_name == merge_group (iOS, Android, wasm, examples)
gradleGitHub merge queueheavierPR commits get Quick Feedback (Linux) and unit tests; queue commits get the full TeamCity pipeline (integration, distribution, performance tests)
rust-lang/rustborsheaviersrc/ci/github-actions/jobs.yml: 11 pr jobs, 82 auto (bors) jobs
traefiklabel bot (serial)samethe bot rebases and waits for the same PR CI
kubernetesProw/TideunknownProw; not visible on GitHub

Queue projects: the real queue against Sheltie

How it was measured

  • Ready time is when a pull request entered the merge process:
    • for GitHub merge queues, its last AddedToMergeQueue event;
    • for bors, the last “approved by” comment;
    • for traefik, when the merge label was added;
    • for Tide, when lgtm, approved, passing presubmits and the removal of blocking labels were all in place.
  • Landed is the pull request’s mergedAt. (A merge-queue commit’s own timestamp is when its build started, not when it landed.)
  • CI per build is the median wall time of a queue build:
    • for GitHub merge queues, merge_group runs grouped by commit, from first start to last finish;
    • for bors, its “Duration” line;
    • for gradle, whose CI runs on TeamCity, from pending to final of the gating status GitHub Merge Queue Check Pass on each landed commit.
  • Sheltie replays the same entries at the same times through the scheduler Sheltie runs (plan_epoch), with the same CI time per run:
    • Each change counts as tested on main as it was when its own pull request CI last started.
    • If anything that landed after that writes what the change reads, Sheltie rebuilds it on the new head. If it writes what the change writes, the change goes to the merge engine.
    • Otherwise it lands at once.
  • Like for like: the comparison uses only pull requests the real queue never ejected, because the replay assumes every build passes.
  • Rebase merges: trino merges pull requests by rebasing several commits onto main. Each such pull request counts as one change, with one footprint from its first parent to its last commit.

Waiting

Waits are median / 90th percentile / total.

ProjectCI per buildPRs (never ejected)Real wait: median / p90 / totalSheltie wait: median / p90 / totalTotal wait saved
rust-analyzer8 min2799 min / 10 min / 43 h 35 min0 s / 8 min / 13 h 57 min68%
junit-framework9 min1479 min / 18 min / 27 h 39 min0 s / 9 min / 6 h 57 min75%
trino53 min45356 min / 1 h 55 min / 22 d 3 h0 s / 1 h 21 min / 9 d 0 h59%
bevy35 min51138 min / 1 h 26 min / 17 d 18 h34 min / 1 h 7 min / 9 d 10 h47%
gradle1 h 19 min2971 h 32 min / 2 h 51 min / 21 d 21 h0 s / 2 h 24 min / 9 d 0 h59%
rust-lang/rust3 h 8 min4285 h 18 min / 17 h 2 min / 155 d 23 h0 s / 3 h 8 min / 19 d 8 h88%
traefik13 min372 min / 15 min / 9 h 37 min0 s / 0 s / 0 s100%

Read with care:

  • rust-lang/rust (bors) runs 11 CI jobs on pull requests and 82 in bors. Landing at once would skip 71 job configurations, so its 88% is not a result Sheltie could deliver as is. Also, 222 of its 440 landings are rollups that bundle 2,151 pull requests; the replay sees a rollup as one change.
  • traefik’s 37 changes in 90 days never overlapped in flight. The bot already lands them within minutes, so the saving is small in absolute terms (9 h 37 min in total).
  • kubernetes is not in the table. Its waits are dominated by holds and policy (up to 35 days), and Prow’s CI is not visible on GitHub, so we have no CI time to replay with.
  • bevy saves least because its changes overlap often. Of 592 changes, Sheltie would send 115 to the merge engine, rebuild 92 and build 57 together with a conflicting change.

CI runs

ProjectQueue CIReal queue builds (failed)Sheltie CI runsSheltie, if PR CI had to run the queue’s full CI
rust-analyzersame297 (7)83not needed
junit-frameworksame165 (5)36not needed
trinosame2357 (579)348not needed
bevyheavier768 (71)224816
gradleheavier≥599 (167)170580
rust-lang/rustheavier625 (190)116556
traefiksame370not needed
  • Real queues build every entry, and rebuild entries behind every failure. Sheltie builds a change again only when something that landed after its CI overlaps it.
  • For gradle the real count is a lower bound: one build per queue entry. Builds re-run after an ejection ahead of an entry are not visible.
  • The last column is the fair cost for projects whose queue runs more than pull request CI: one full CI run per change plus Sheltie’s rebuilds. It is about the same as the real queue.

What Sheltie would have done with each change:

ProjectChangesLand at onceRebuild (reverify)Merge engineBuilt togetherLeaky footprints
rust-analyzer289203 (70%)35465160 (55%)
junit-framework228191 (84%)334024 (11%)
trino746391 (52%)862618395 (53%)
bevy592328 (55%)9211557282 (48%)
gradle410239 (58%)301392129 (31%)
rust-lang/rust440318 (72%)743810417 (95%)
traefik3737 (100%)00025 (68%)

Would landing at once have let breakage through?

Queue failures, split by what Sheltie would have done with the same pull request:

ProjectFailed in queue, Sheltie lands at onceFailed in queue, Sheltie rebuilds or merges
trino112 / 388 (29%)169 / 355 (48%)
gradle58 / 239 (24%)40 / 171 (23%)
bevy44 / 328 (13%)14 / 264 (5%)
junit-framework5 / 117 (4%)0 / 35 (0%)
rust-analyzer4 / 203 (2%)2 / 86 (2%)

Except at trino, queue failures hit pull requests that Sheltie would land at once as often as, or more often than, those it would rebuild. At trino and gradle, about a quarter of the pull requests Sheltie would land at once failed in the queue at least once. Footprint overlap is a weak predictor of which queue build fails.

Of the 223 pull requests that failed in the queue and that Sheltie would land at once, 165 went back into the queue with no new commits and merged. Those failures were most likely flaky or caused by another entry in the same build; we did not check each one. To find the rest, we took every pull request that failed in the queue, that Sheltie would land at once, and that got new commits afterwards. For each one we compared the pull request’s own diff at the time of the failure with its final diff, and read the failed jobs and follow-up commits:

Outcome after the queue failuretrinogradlebevyjunit-frameworkrust-analyzerTotal
change to CI workflow, only exercised in the queue200002
code change after a PR-CI job failed in the queue; cause not determined001001
code change; failing job and cause not determined500027
failing jobs run only in the queue (wasm, iOS/Android, examples, unused deps)004004
repository-wide check (distribution size, API list, version list, performance baseline)01000010
semantic conflict with main (“Fix example merge conflict”)001001
test fix after a PR-CI job failed in the queue; cause not determined001001
test or code fix; PR CI is a subset of queue CI, failing test not identified01400014
diff unchanged: rebase or merge of main only5481018
All candidates1228151258
  • 18 only rebased or merged main; their diff did not change.
  • 14 failed checks that run only in the queue, which Sheltie would need on the pull request:
    • 10 are repository-wide checks at gradle (distribution size, accepted API list, version lists, performance baselines). Any change can break these in combination with others, and no footprint captures them.
    • 4 at bevy, whose failing jobs run only in the queue.
  • 14 are other gradle test or code fixes after a queue failure. Gradle’s pull request CI is a subset of its queue CI, so the failing test most likely did not run on the pull request, but we did not identify it (gradle’s queue CI is on TeamCity, outside the GitHub API).
  • 2 changed trino’s CI workflows, which are only exercised in the queue.
  • 1 is a semantic conflict with main (bevy: “Fix example merge conflict”). Footprints should have caught it and did not.
  • 9 have a code change whose cause we could not determine: 5 at trino, 2 at rust-analyzer, 2 at bevy.

This is the honest worst case: on the three projects whose queue runs the same CI as pull requests (1,263 changes), 9 pull requests changed after a queue failure, and Sheltie would have landed them without a rebuild. For 7 of them we cannot say whether the failure was an interaction footprints missed. In those projects Sheltie’s sweeper (post-landing CI with bisect and revert) would be the safety net. We did not simulate it.

Projects without a merge queue

How it was measured

  • CI is the build and test workflows that run both on pull requests and on pushes to main. Security audits, release, CodeQL, Copilot setup, zizmor, typos, CLA and labeller workflows are left out.
  • Stale at merge: something else landed between the start of the pull request’s last CI and its merge.
  • Sheltie would rebuild a pull request when one of those landings writes something it reads.
  • Extra CI costs each such rebuild one full pull request CI run of wall time. That is an upper bound: Sheltie reruns only affected tests.
  • Persistent red episode: two or more consecutive failed runs of a CI workflow on main. The candidates are the landings since the last green run. An episode counts as “with an overlapping landing” when a candidate was stale and something that landed in its gap overlaps it.
  • Chance baseline: how many episodes would show an overlapping landing if overlap had nothing to do with breaking. Each candidate is taken to overlap at that project’s overall rate.

Results

ProjectAdapterPRsPR CIStale at mergeSheltie would rebuildExtra CI (wall time, upper bound)Leaky footprintsPersistent red episodes…with an overlapping landing: seen vs by chance
ruffcargo138812 min726/1295 (56%)440 (34%)3 d 23 h790 (61%)62 vs 2.4
nushellcargo36423 min186/346 (54%)87 (25%)33 h 21 min179 (52%)00 vs 0.0
tokiocargo1631 h 16 min75/162 (46%)36 (22%)45 h 38 min8 (5%)10 vs 0.2
hugogo1441 h 5 min81/143 (57%)64 (45%)2 d 21 h21 (15%)51 vs 2.2
prometheusgo41428 min325/414 (79%)220 (53%)4 d 6 h95 (23%)2012 vs 10.6
okhttpgradle14729 min21/38 (55%)14 (37%)6 h 59 min6 (16%)22 vs 0.7
keycloakmaven84459 min758/840 (90%)559 (67%)23 d 4 h515 (61%)n/mn/m
vscodenode52218 min4675/5216 (90%)4550 (87%)28 d 4 h5110 (98%)1110 vs 9.6
storybooknode142n/m89/125 (71%)74 (59%)n/m8 (6%)n/mn/m
vitenode3216 min162/317 (51%)98 (31%)11 h 9 min36 (11%)217 vs 6.5
vitestnode37015 min76/367 (21%)57 (16%)14 h 19 min19 (5%)302 vs 6.0
fastapipython1785 min57/178 (32%)11 (6%)1 h 3 min10 (6%)10 vs 0.1
home-assistantpython62934 min5660/6291 (90%)2068 (33%)6 d 3 h3029 (48%)5234 vs 33.6
pydanticpython22012 min77/220 (35%)55 (25%)11 h 16 min54 (25%)52 vs 1.2
cpythonpython121132 min919/1211 (76%)691 (57%)15 d 10 h914 (75%)2211 vs 12.6
Total17683 vs 85.8

n/m means not measurable:

  • keycloak runs no CI on pushes to main, so red main is invisible;
  • storybook’s main CI is not on GitHub Actions;
  • only 38 of okhttp’s 147 pull requests had matching CI suites.

All persistent red episodes, by what their candidates looked like:

  • stale base, overlapping landing: Sheltie rebuilds before landing: 83
  • tested on the current main: not an interaction: 64
  • stale base, no overlap: Sheltie lands it too: 27
  • direct push or untested change in range: 2
  • Stale merges are the norm, but they do not explain red main. Across 176 persistent episodes, an overlapping landing was in range 83 times, against 85.8 expected by chance. We found no evidence that Sheltie’s rebuilds would have prevented these breakages.
  • 64 episodes started at changes tested on the current main. Flaky tests, infrastructure and environment changes break main without any interaction; vitest alone had 26.
  • 27 episodes had stale changes with no overlap. If any of them was a real interaction, Sheltie’s footprints would have missed it too.
  • Sheltie would cost CI here. It would rebuild 6–87% of pull requests. The largest shares come from coarse footprints: vscode’s root install script puts the whole repository in every read set, and keycloak’s Maven build plugins do the same for its test suite.

Adapter coverage

A footprint is leaky when the change reads something the adapter cannot model exactly:

  • a build script;
  • a proc macro;
  • an install script;
  • a build plugin;
  • a file outside the module graph.

Its read set then widens to everything that leak could reach. Leaky does not mean serial, but it makes rebuilds more likely. This is the share of landings with a leaky footprint after the fixes below:

ProjectAdapterLeaky footprintsMost common reason
ruffcargo858/1416 (61%)build script crates/ty/build.rs
bevycargo282/592 (48%)included file ci_testing_config.ron not in tree
fastapipython10/326 (3%)scripts/translate.py is outside the module graph
hugogo35/182 (19%)testscripts/commands/no_symlinks.txt is outside the module graph
gradlegradle129/410 (31%)released-versions.json is outside the module graph
home-assistantpython3029/6298 (48%)setup.py builds extensions
junit-frameworkgradle24/228 (11%)documentation/.tool-versions is outside the module graph
keycloakmaven517/846 (61%)build plugin runs code (maven-antrun-plugin)
kubernetesgo271/925 (29%)test/compatibility_lifecycle/reference/versioned_feature_list.yaml is outside…
vscodenode5172/5282 (98%)install script
nushellcargo195/368 (53%)build script scripts/build.rs
prometheusgo95/415 (23%)web/ui/pnpm-lock.yaml is outside the module graph
pydanticpython54/221 (24%)pydantic-core/src/serializers/shared.rs is outside the module graph
cpythonpython917/1214 (76%)setup.py builds extensions
rust-lang/rustcargo417/440 (95%)cannot parse compiler/rustc_trait_selection/src/error_reporting/infer/need_ty…
rust-analyzercargo160/289 (55%)build script crates/rust-analyzer/build.rs
okhttpgradle9/153 (6%)okhttp/api/android/okhttp.api is outside the module graph
storybooknode18/162 (11%)scripts/build/entry-configs.ts is outside the module graph
tokiocargo8/166 (5%)proc-macro crate
traefikgo25/37 (68%)integration/gateway-api-conformance-reports/v1.6.1/experimental-v3.8-default-…
trinomaven395/746 (53%)build plugin runs code (exec-maven-plugin)
vitenode37/328 (11%)vitest.config.e2e.ts is outside the module graph
vitestnode19/370 (5%)knip.jsonc is outside the module graph

The causes that remain are mostly deliberate caution:

  • Code that runs at build time: Cargo build scripts and proc macros, npm install scripts, Maven plugins that run code, Python setup.py extensions.
  • Languages an adapter does not read:
    • ruff’s Python packaging;
    • pydantic’s Rust core;
    • prometheus’ web UI;
    • files under test/ read at run time.
  • rust-lang/rust (95%): syn cannot parse the unstable syntax the compiler uses, so Sheltie claims nothing about those crates.
  • bevy: a wasm32-only include_str! of a file that CI generates.

What the study changed in Sheltie

Problem foundEffect in the dataFix
Cargo: mod r#type; and #[path] in non-mod.rs files not resolvedleaky footprints: rust-analyzer 64% → 55%, ruff 76% → 61%#104
Cargo: modules declared inside macros (cfg_rt! { mod pool; }) not followedtokio 71% → 5%sheltie-cargo follows mod items in macro bodies
Go: every //go:embed made a package non-hermetichugo 76% → 21%, prometheus 62% → 23%, kubernetes 50% → 29%embed patterns resolved by the go command’s rules; embedded files belong to the package
Node: a package.json with a byte order mark was rejectedvite 25% → 11%the mark is stripped, as Node does
.circleci/** treated as build inputhugo 21% → 19%plain for every adapter, like .github/**
Two checkouts of one commit in one process collided14 of about 24,000 footprints failed in the benchworktree names are unique per call
ROI report assumed every change was tested on main as of landingWith that assumption, Sheltie needed 0 CI runs on five of six queue projects (3 at junit) instead of 36–348. The shipped report overstated savings.the report finds each change’s base from merge commits or fetched pull request heads, and says how many changes lack one (ROI report)

What we cannot prove

These are the limits of this study. Read every result above with them in mind.

  • These are simulations. No project ran Sheltie.
    • The replay assumes every CI run passes and every merge-engine resolution succeeds.
    • It uses one CI time per project (the median) for every build.
    • Real projects would also change how they work if they adopted it.
  • Whether Sheltie is safe to land at once on these projects. 9 pull requests on projects with equal queue and pull request CI changed after a queue failure that Sheltie would have skipped. For 7 of them we could not identify the failing job or the cause. The causes of the other 31 changed pull requests come from reading their commits and changed files, and for 14 at gradle the failing test was not identified. We did not rerun anything.
  • Why main went red. The red-episode analysis is statistical. We did not read the 176 failures, and the chance baseline treats overlap as independent of everything else.
  • Required checks. GitHub does not expose which checks a project requires, so failures are counted from the queue’s own ejection events, not from workflow conclusions.
  • Ready time is inferred from timeline events. Review and policy delays that happen outside those events count as queue time; this is why kubernetes is left out.
  • The sample is not random. We picked popular projects with clear setups across six build tools.
    • They are mostly human-written pull requests; Sheltie is built for agent-generated ones, where volume and overlap are higher.
    • Nothing here measures that case.
  • One window. 90 days, ending 2026-10-19. Release cycles and freezes inside it are not separated out.

What was verified, and how

ClaimHow it was checkedVerified
Each project’s merge setupmerge_group runs, bors comments, Tide statuses, label events in the collected dataYes
Merge-queue landing time is mergedAt, not the commit timethe queue commit’s committer time equals its build start (checked on junit)Yes
Queue CI vs pull request CIworkflow files (bevy validation-jobs.yml, trino ci.yml, rust jobs.yml: 11 PR / 82 auto jobs); gradle statuses on PR and queue commitsYes
gradle queue CI time (1 h 19 min)GitHub Merge Queue Check Pass status on all 410 landed queue commitsYes
Ejections and their reasonsRemovedFromMergeQueueEvent.reasonYes
Footprints are what Sheltie computesthe released adapters at each landing; 0 errors in the final setYes
The replay is Sheltie’sthe bench checks every replay against sheltie_scheduler::roi::replay and stops on any differenceYes
18 escape candidates only rebasedpull request diff at the failure equals the final diffYes
Causes of the 40 escape candidates whose diff changedread from failed jobs, follow-up commits and changed filesPartly (9 undetermined; for 14 at gradle the failing test is not identified)
Red main is not explained by stale overlapstatistical baseline onlyNo (no per-episode cause)
Required status checksnot publicNo
Sheltie’s behaviour in production on these projectssimulation onlyNo

Reproduce it

research/real-world/README.md lists the steps: survey, collection, footprints, analyses and table generation. results/tables.md holds the tables above, generated from results/*.json.