You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
A regenerated Cargo.lock can silently un-land a security fix: nothing asserts on lock contents, and a merge resolved rustls back below its RUSTSEC bump #10980
Main is healthy. This is about the class, which is unguarded, and about one branch where it already happened.
What happened
aeaa912c1d (2026-09-21) — "deps: bump rustls to 0.23.45 for RUSTSEC-2026-0285". upstream/main's lock has 0.23.45. Correct.
upstream/turnloop/integrationcontains that commit in its history and its Cargo.lock resolves rustls to 0.23.44.
The lock there was last written by a915e21f8a — "Merge origin/main into turnloop/integration" (2026-09-22), i.e. after the bump, by a merge that regenerated the file. The fix is in the history and gone from the artifact that decides what gets built.
Note the discriminator, because it is what any gate has to get right: plenty of branches also show 0.23.44 and are fine — they simply predate the bump. The failure is specifically "contains the fix, resolves below it".
Why nothing caught it
Cargo.lock appears in .github/workflowsonly as a cache key (hashFiles('**/Cargo.lock')). No job asserts anything about its contents. So:
the lock is an input that decides every built artifact, and
it is regenerated by tooling during merges, and
no test can fail when it moves the wrong way.
security-audit would eventually notice, but only when it happened to run on a branch carrying the regression — which is discovery by luck, in a job that did not cause it. That is the same shape as #10944 (a suite that cannot attribute) and #7672 (a defence that is opt-in and invisible at the site that needs it).
Proposed gate — one-directional, cheap
On a merge, compare the post-merge lock against the pre-merge lock and fail on any package whose resolved version decreases. Upgrades pass. Additions pass. Removals pass.
One direction only, deliberately: a downgrade is never what a merge intends, whereas an upgrade routinely is, so the rule needs no allowlist and no baseline. It also needs no knowledge of advisories — it does not ask "is this version vulnerable?", only "did this merge move a pin backwards?" — which keeps it correct without a vulnerability database and makes it catch the general case, not just rustls.
Failure message should name the package and both versions, because "the lock changed" is not actionable and "rustls 0.23.45 → 0.23.44" is.
Scope
Merge tooling, coordinated with merge-agent. It is not a per-PR lint: a PR that legitimately predates a bump is not at fault, and gating those would train people to bypass it. The merge is the moment the two locks exist side by side and the resolution is decided.
I am implementing this; filing first so the evidence is recorded independently of the fix.
ci(#10980): fail a merge that lowers a resolved dependency version #10982 added scripts/lock_no_downgrade.py: a per-consumer predicate, not per-package-max. A dependency counts as downgraded only when a consumer present in both locks resolves it lower; a higher version that left with all its consumers is reported as a removal and does not fail. That distinction is what makes it trustworthy when it goes red — the max-over-versions form produced two false findings out of three on the real locks. Pre-releases sort below their release, and a comparison that checks zero edges fails rather than passing.
ci: run lock downgrade gate on pull requests #11032 wired the real comparison into CI per-PR (train 257, 990b3eeada). actions/checkout gives the synthetic merge commit for a pull_request event, so the gate compares that tree against the event's base SHA — which asks exactly the right question, would merging this move a shared consumer's pin backwards, while a branch that merely predates a bump inherits the base's lock and stays green. The self-test runs alongside it, so the detector is proven able to fail on every run.
That closes the gap this issue identified: the lock is no longer a file nothing asserts on.
The specific instance is also resolved — #10354 landed with rustls at 0.23.45, verified by this predicate at merge time. Worth noting for anyone reading later: the recurrence risk is real and separately tracked in #11025, where tempfile's getrandom range lets a regeneration re-pick a lower major, which happened on three consecutive merges of that branch.
Activity
ci(#10980): fail a merge that lowers a resolved dependency version
ci(#10980): fail a merge that lowers a resolved dependency version
ci(#10980): fail a merge that lowers a resolved dependency version
ci(#10980): fail a merge that lowers a resolved dependency version
proggeramlug commentedon Sep 23, 2026
Fixed on main, in two parts, both landed:
ci(#10980): fail a merge that lowers a resolved dependency version #10982 added
scripts/lock_no_downgrade.py: a per-consumer predicate, not per-package-max. A dependency counts as downgraded only when a consumer present in both locks resolves it lower; a higher version that left with all its consumers is reported as a removal and does not fail. That distinction is what makes it trustworthy when it goes red — the max-over-versions form produced two false findings out of three on the real locks. Pre-releases sort below their release, and a comparison that checks zero edges fails rather than passing.ci: run lock downgrade gate on pull requests #11032 wired the real comparison into CI per-PR (train 257,
990b3eeada).actions/checkoutgives the synthetic merge commit for apull_requestevent, so the gate compares that tree against the event's base SHA — which asks exactly the right question, would merging this move a shared consumer's pin backwards, while a branch that merely predates a bump inherits the base's lock and stays green. The self-test runs alongside it, so the detector is proven able to fail on every run.That closes the gap this issue identified: the lock is no longer a file nothing asserts on.
The specific instance is also resolved — #10354 landed with rustls at 0.23.45, verified by this predicate at merge time. Worth noting for anyone reading later: the recurrence risk is real and separately tracked in #11025, where
tempfile'sgetrandomrange lets a regeneration re-pick a lower major, which happened on three consecutive merges of that branch.