Skip to content

ci(deps): keep major bumps out of the grouped pull request, and split the cadence - #484

Merged
PathGao merged 1 commit into
masterfrom
ci/dependabot-majors
Aug 6, 2026
Merged

ci(deps): keep major bumps out of the grouped pull request, and split the cadence#484
PathGao merged 1 commit into
masterfrom
ci/dependabot-majors

Conversation

@PathGao

@PathGao PathGao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

.github/dependabot.yml groups each ecosystem with patterns: ['*'] and no update-types filter, so a major version bump lands inside the monthly grouped pull request. The first run showed what that covers:

PR contents crossed
#476 4 actions major tags v4 → v7 — merged
#477 11 npm packages TypeScript 5.6 → 7.0, Vite 6 → 8
#478 17 crates tauri-plugin-prevent-default 2 → 5, notify 6 → 8

Nothing failed — Dependabot's schema check accepts the config. It just produced a chore(deps) title wrapped around a toolchain migration, in a shape that cannot be split, bisected, or reverted per dependency.

Each group now takes update-types: [minor, patch], and open-pull-requests-limit goes 2 → 4.

Does this make majors individual, or does it drop them?

That distinction decides whether this fixes the problem or buries it, so I checked it rather than assuming.

Documentation. GitHub's Optimizing the creation of pull requests for Dependabot version updates carries two adjacent examples:

  • "Example: Individual pull requests for major updates and grouped for minor/patch updates" — a group with patterns + update-types: [minor, patch], i.e. this config's exact shape. Its stated result: "All major updates will continue to be raised as individual pull requests."
  • "Example: Grouped pull requests for minor/patch updates and no pull requests for major updates" — the same group plus an ignore condition on version-update:semver-major.

Suppressing majors takes that extra ignore. update-types alone does not, and no ignore is added here.

Implementation, read in dependabot-core at main, because the docs and the behaviour have been reported to diverge:

  • DependencyGroup#contains? (common/lib/dependabot/dependency_group.rb) checks patterns, exclude-patterns and dependency-typenot update_types.
  • GroupUpdateCreation#compile_updates_for calls semver_rules_allow_grouping?, which returns false for a major when update_types is [minor, patch], and then returns early via mark_handled_for_group_by_name, which is a no-op unless the group uses group-by: dependency-name. We don't. So the dependency is never added to handled_dependencies.
  • DependencySnapshot#ungrouped_dependencies is allowed_dependencies minus handled_dependencies, and GroupUpdateAllVersions#perform runs run_ungrouped_dependency_updates after the grouped pass, delegating to UpdateAllVersions — one pull request per dependency.

Majors are excluded from the group and opened individually. The change is right.

What I could not verify

  • I did not run Dependabot. This is a bot config; it only executes on GitHub's schedule. Everything above is documentation plus source reading, not observed behaviour. The real proof is the next run.
  • I read dependabot-core at main, not the revision GitHub's hosted Dependabot runs. That revision isn't publicly pinned.
  • A known divergence, which I have written into the config comment. dependabot-core#14202 (open, filed 2026-02-17) reports majors being suppressed entirely under this config shape. I traced the mechanism: when a grouped pull request is already open, GroupUpdateAllVersions calls mark_group_handled, which does add_handled_dependencies(group.dependencies.map(&:name)) — and group.dependencies was populated by contains?, which ignores update-types. So on such a run, no individual major pull requests open at all; they appear on the first run after the grouped PR is merged or closed. Majors are deferred by an unreviewed batch, not dropped by it — but they are deferred. The reporter's own job log shows exactly this path. I could not reproduce it, only trace it.
  • open-pull-requests-limit: 4 is a judgement, not a measurement. One slot for the grouped PR, three for majors. I have not observed how many majors a real run opens at once.

Also found, and recorded in the config comment

#477 and #478 are both red, and one cause is a coupling Dependabot structurally cannot see. The Tauri plugins ship as coupled crate+npm pairs, and tauri build refuses to run when the halves disagree — from #478's Linux and macOS legs:

Found version mismatched Tauri packages. Make sure the NPM package and Rust
crate versions are on the same major/minor releases:
tauri-plugin-dialog (v2.7.2) : @tauri-apps/plugin-dialog (v2.6.0)

The crate half is in #478, the npm half in #477; neither is green alone. The guard compares major and minor, so this change does not avoid it — a minor-only grouped batch can split a plugin pair the same way. That is why it is written into dependabot.yml rather than left to be rediscovered.

One thing I deliberately did not do

#478's Windows leg fails differently, and it is not a grouping problem:

error[E0599]: no method named `cast` found for struct ...ICoreWebView2
note: there are multiple different versions of crate `windows_core` in the dependency graph

src-tauri/src/lib.rs:1966 and :1972. windows-core 0.61.2 (via tauri → webview2-com) and 0.62.2 (via our own windows bump) are both in the graph. I checked crates.io: tauri 2.11.5, the current latest, still requires windows ^0.61 and webview2-com ^0.38. So bumping our windows = "0.61.3" to 0.62 is unmergeable at any published tauri 2.x, and it will come back every month.

An ignore entry would silence it, and I did not add one — that is a compatibility call, and a config ignore with no expiry goes stale silently the moment tauri moves. Three options, for you rather than for me:

  1. Do nothing. It reappears monthly and gets closed monthly. Honest, noisy.
  2. ignore windows and webview2-com with a comment naming the unblocking condition (tauri requiring windows ^0.62). Quiet, but needs a human to notice when it expires.
  3. Pin them to tauri's requirement in Cargo.toml (windows = "0.61", webview2-com = "0.38") so Dependabot stops proposing an incompatible bump. Puts the constraint where the constraint actually lives.

Tests

scripts/dependabotConfig.test.ts locks the properties that are invisible from reading the file: no group lists or admits major, every group stays applies-to: version-updates, the interval matches the cadence the header comment promises, and the limit stays above the point where the queue becomes invisible.

Each was verified by breaking the property it protects and confirming that test goes red:

broken test that failed message
removed the github-actions ecosystem every ecosystem the repository has is configured Expected values to be strictly deep-equal: - 'github-actions'
npm group loses update-types no group may batch a major version the npm group must limit itself to minor and patch; without that, a major lands inside a grouped pull request and stops being reviewable
npm group gains - major no group may batch a major version the npm group must not list major among its update types
cargo group loses applies-to a security fix never waits for the monthly batch the cargo group must apply to version updates only
npm schedule → weekly the schedule is the cadence the header comment promises the header comment promises one grouped pull request a month, so npm must be on \interval: monthly``
header comment → "a week" the schedule is the cadence the header comment promises the header comment promises one grouped pull request a week, so npm must be on \interval: weekly``
npm limit back to 2 the open pull request limit leaves room for majors to queue npm has a limit of 2; majors open one pull request each, so anything under 3 hides the queue rather than shortening it

Two of those tests changed as a result of running this:

  • The cadence test asserted the literal interval: monthly while being named for the header comment. Editing the comment to promise a weekly bot left it green — the name was a claim the test did not make. It now reads the cadence out of the comment, which is what makes it a contract between two copies of one fact rather than a constant checking itself.
  • Adding one space to each - package-ecosystem: line made ecosystemBlocks() return [], and four of the five checks passed on the empty list — every one of them is a for over that result. ecosystemBlocks() now asserts it found something, so the detector states its own precondition instead of relying on a sibling test to notice.

npm run check — 0 errors. npm test — 733/733 locally. Those numbers are from before this
branch was rebased onto current master; the run on this PR is the one to trust.


Second commit: cadence split, two held crates, and one change left out

Cadence

npm and cargoquarterly; github-actions stays monthly.

Security advisories never use this schedule, so version updates exist only to stop drift — and drift costs what someone else's deadline costs. On Actions somebody else always sets it: runner images retire toolchains on their own timetable, which is exactly how build.yml came to build releases on a Node pulled from the toolcache (#485). An Actions bump is also usually a one-line tag move. Nothing external forces a TypeScript or serde upgrade.

quarterly verified as a documented schedule.interval value — "run on the first day of each quarter (January, April, July, and October)". (It is fpt/GHEC plus GHES ≥ 3.19; this repo is on github.com.)

Two crates held, and the shape the obvious version would have got wrong

tauri requires windows ^0.61 and webview2-com ^0.38 — still true at 2.11.5, the current latest. #478's bumps are windows 0.61.3 → 0.62.2 and webview2-com 0.38.2 → 0.39.1, which collide two windows-core versions and fail at src-tauri/src/lib.rs:1966.

An ignore on version-update:semver-major would have been a no-op here, and I nearly wrote one. These are 0.x crates. IgnoreCondition#versions_by_type delegates to Version#ignored_major_versions, which for 0.61.3 builds [">= 1.a"] — 0.62.2 passes straight through. The bump Dependabot needs to skip is classified minor (new_minor > current_minor). So the entries use bounded versions: ranges instead:

ignore:
  - dependency-name: windows
    versions: ['0.62.*']
  - dependency-name: webview2-com
    versions: ['0.39.*']

That also gives the lapse property: windows 0.63 and webview2-com 0.40 do not exist yet, so on the day they ship Dependabot proposes them and the question gets asked again.

One hazard worth naming: ignore is the only option here that also filters security updates — ignored_versions does return versions if security_updates_only, so a versions: entry applies to advisories while an update-types: entry does not. The exposure is nil in practice because Cargo.toml pins windows = "0.61.3" (^0.61.3 = >=0.61.3, <0.62.0), so 0.62.x is already outside what Cargo will resolve; advisories against the 0.61.x we actually build are untouched. It is in the config comment regardless.

The Tauri packages are not ignored, and the file now says why: the lockfile is on tauri 2.10.2 while 2.11.5 shipped 2026-07-01, and the red PR is the only notification that exists.

The cooldown is not in this PR

The mechanism checks out. cooldown is valid alongside groups; dependabot-core passes job.cooldown on the grouped path (group_update_creation.rb:404) and the individual path (update_all_versions.rb:249) — so it would reach the individual major PRs, the case that matters; and it cannot delay a security update, both by update_cooldown: job.security_updates_only? ? nil : job.cooldown on both paths and by the docs ("only available for version updates, not security updates").

What does not hold is the premise. None of the current red PRs is red because its target is fresh:

red item actual cause age of target
typescript 7.0.2 vs svelte-check svelte-check 4.7.4 (2026‑07‑27, 19 days after TS 7.0.2) narrowed its peer range to ^5.0.0 || ^6.0.0 — a deliberate exclusion 29 days
windows 0.62.2 tauri requires ^0.61 10 months (2025‑10‑06)
webview2-com 0.39.1 tauri requires ^0.38 5 months (2026‑03‑11)
tauri plugin pair cross-ecosystem parity guard, not age n/a

And the cadence change makes a cooldown actively costly: on a quarterly schedule a major caught by an N-day window is not delayed N days, it is delayed to the next quarter. quarterly already means a proposed major is typically weeks old.

Also worth recording: GitHub Actions does not support semver-major-days at all (docs table: default-days only), so a cooldown could never have applied to the one ecosystem staying monthly.

Happy to add it if you'd rather — semver-major-days: 7 on npm and cargo is the shape — but I'd be shipping something I can't show a benefit for.

Falsification for every test touched

Per-ecosystem, as asked — each cadence mutation names the block it broke:

broken message
npm block → monthly the header comment promises npm runs quarterly, but its block says monthly
cargo block → monthly the header comment promises cargo runs quarterly, but its block says monthly
github-actions block → quarterly the header comment promises github-actions runs monthly, but its block says quarterly
header row for npm → monthly the header comment promises npm runs monthly, but its block says quarterly
header row for github-actions deleted the header comment must state a cadence for every ecosystem and no others
ignore entry loses versions: every ignore entry under cargo must carry a versions: list; an entry without one silences the dependency permanently, including its security updates
ignore range → '>= 0.62' ignoring windows at '>= 0.62' does not name a bounded version series, so nothing will ever reopen the question
ignore range → '0.*' ignoring windows at '0.*' does not name a bounded version series, so nothing will ever reopen the question
blank line inside ignore: cargo has an \ignore:` key this test cannot parse, so it cannot vouch for it`

The four earlier properties were re-falsified after the rewrite and still fail correctly, and re-indenting - package-ecosystem: still turns all six red rather than passing vacuously.

The ignore test had the same vacuity hole as the first round — it continued when its parser found nothing. It now asks "does this block ignore anything" separately from "can I read it", which is what the last mutation above exercises.

npm run check — 0 errors, 652 files. npm test — 752/752.

🤖 Generated with Claude Code

#472 gave each ecosystem one grouped pull request a month with
`patterns: ['*']` and no `update-types` filter. The first run showed what
that grouping actually covers:

| PR   | contents  | crossed                                             |
|------|-----------|-----------------------------------------------------|
| #476 | 4 actions | major tags v4 -> v7, merged                          |
| #477 | 11 npm    | TypeScript 5.6 -> 7.0, Vite 6 -> 8                   |
| #478 | 17 crates | tauri-plugin-prevent-default 2 -> 5, notify 6 -> 8   |

Nothing failed. Dependabot's schema check accepts the config, and the
result is a `chore(deps)` title wrapped around a toolchain migration --
a diff whose per-dependency changelogs are the review, presented as a
batch that cannot be split, bisected, or reverted per dependency.

So each group now takes `update-types: [minor, patch]`. Majors fall
outside every group and arrive one pull request per dependency, each
carrying its own changelog. That is the documented shape: "Example:
Individual pull requests for major updates and grouped for minor/patch
updates" in GitHub's guide to optimizing pull request creation, whose
stated result is "All major updates will continue to be raised as
individual pull requests."

`update-types` alone does not suppress anything. The next example in the
same guide, the one titled "no pull requests for major updates", starts
from this exact group and reaches that outcome by *adding* an `ignore`
condition on `version-update:semver-major`. That condition is
deliberately not written here: an `ignore` is a permanent,
repo-invisible mute, and the point of this change is to make majors
visible one at a time, not to stop hearing about them.

`open-pull-requests-limit` goes 2 -> 4 because the shape of a run
changed. Two was sized for "one grouped pull request, plus headroom for
a stale one". With majors opening individually the file now holds one
grouped pull request plus n majors, and a limit of 2 leaves room for
exactly one of them.

One caveat, which is in the config comment because it is invisible from
the config: on a run where a grouped pull request is already open,
Dependabot marks every dependency matching the group's `patterns` as
handled before it consults `update-types`, so that run opens no
individual major pull requests at all. They appear on the first run
after the grouped pull request is merged or closed. Majors are deferred
by an unreviewed batch, not dropped by it.

Separately, and recorded in the config comment because nothing else in
the repository says it: `tauri build` fails when a Tauri plugin's crate
and npm halves disagree on major *or minor*, and Dependabot puts those
two halves in two different pull requests. #478 is red on Linux and
macOS for exactly that -- `tauri-plugin-dialog (v2.7.2) :
@tauri-apps/plugin-dialog (v2.6.0)`, the npm half sitting in #477. That
coupling is not a semver problem and this change does not fix it; the
pair has to be merged as a pair.

The github-actions comment is corrected while it is being rewritten: it
claimed all five actions are tracked by major tag. Four are;
`dtolnay/rust-toolchain@stable` carries no version for Dependabot to
compare.

scripts/dependabotConfig.test.ts locks the four properties this file has
that a reader cannot see: no group lists or admits `major`, every group
stays `applies-to: version-updates` so a security advisory never waits
for the monthly batch, the `interval` matches the cadence the header
comment promises, and the limit stays above the point where the queue
becomes invisible. Each was verified to fail by breaking the property it
claims to protect.

`ecosystemBlocks()` asserts it found blocks, because every check is a
`for` over its result and a `for` over an empty list passes. Measured: an
extra space in each `- package-ecosystem:` line left four of the five
checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 8674be8 into master Aug 6, 2026
4 checks passed
@PathGao PathGao changed the title ci(deps): keep major bumps out of the grouped dependency pull request ci(deps): keep major bumps out of the grouped pull request, and split the cadence Aug 6, 2026
PathGao added a commit that referenced this pull request Aug 6, 2026
….0 npm (#490)

Follow-up to #484, which is now doing its job -- the recreated cargo
batch (#489) came back with 7 minor/patch crates instead of 16 with
majors inside. Three things that first pass did not cover, and one that
was planned and is not here.

**Cadence.** npm and cargo move to `quarterly`; github-actions stays
`monthly`. Security advisories never use this schedule -- they are a
repository setting and open their own pull requests as they land -- so
version updates exist only to stop drift, and drift costs what somebody
else's deadline costs. On Actions somebody else always sets it: runner
images retire toolchains on their own timetable, which is how `build.yml`
came to build releases on a Node pulled from the image toolcache (#485).
An Actions bump is also usually a one-line tag move. Nothing external
forces a TypeScript or a serde upgrade, and a quarter of them in one
grouped pull request is less interrupting and more reviewable than three
monthly ones.

**`minor` does not mean compatible below 1.0, and the two ecosystems
disagree about it.** Dependabot types an npm update from the literal
position of the number, so katex 0.16.47 -> 0.18.1 is a `minor` and went
straight into #487 -- the batch whose whole premise is that it is safe to
skim. It is not: KaTeX 0.18.0 renamed every internal CSS class and 0.17.0
changed `__defineFunction`, both flagged BREAKING upstream. npm's own
caret rule agrees, since `^0.16.47` stops before 0.17.

Cargo does not have this bug. `Dependabot::Cargo::Version` implements the
pre-1.0 rule, so windows 0.61.3 -> 0.62.2 counts as a major -- which is
visible in #489, where those crates are absent from the batch. The npm
side has no equivalent subclass.

There is no `update-types` value for "pre-1.0 minor", and `patterns`
match names rather than versions, so this cannot be written as a rule.
It can be written as a list: `exclude-patterns` names the three pre-1.0
npm dependencies, and they get individual pull requests. The test derives
that list from package.json, so a new 0.x dependency -- or an existing
one reaching 1.0 -- fails the suite instead of quietly rejoining the
batch.

**Two crates held.** `tauri` requires `windows ^0.61` and
`webview2-com ^0.38`, still true at 2.11.5. Taking `windows` 0.62 or
`webview2-com` 0.39 puts two `windows-core` versions in one graph and the
build stops in our own WebView2 call with E0599. Grouping already keeps
them out of the batch; what is left is an individual pull request every
quarter that cannot be merged at any published tauri 2.x.

Bounded to one minor series each rather than written as a semver level,
so the entries lapse. The semver-level form would work -- `Cargo::Version`
overrides `ignored_major_versions` so ">= 0.62" is what major means for a
0.61 crate -- but it would also swallow 0.63 and 0.64 in silence.
`windows` 0.63 does not exist yet; the day it does, Dependabot proposes
it and we find out whether tauri has moved.

The Tauri packages themselves are deliberately not ignored, and the file
says so: the lockfile is on 2.10.2 while 2.11.5 shipped 2026-07-01, and
the red pull request is the only notification there is.

**The cooldown is not here, because the case for it does not survive
being checked.** The mechanism is fine -- `cooldown` is valid alongside
`groups`, dependabot-core passes it on both the grouped and the individual
path, and it cannot touch a security update
(`update_cooldown: job.security_updates_only? ? nil : job.cooldown` in
both). What fails is the premise that the red pull requests are red
because their targets are fresh:

  - typescript 7.0.2 shipped 2026-07-08; svelte-check 4.7.4 shipped
    2026-07-27, nineteen days later, and narrowed its peer range to
    `^5.0.0 || ^6.0.0` -- excluding 7 on purpose.
  - windows 0.62.2 has been out since 2025-10-06 and webview2-com 0.39.1
    since 2026-03-11. Ten months and five.
  - katex 0.18.1 is three weeks old, but it is breaking at any age.
  - the rest is the Tauri parity guard, which is not about age at all.

None of them is a freshness problem. And on a quarterly schedule a major
caught by an N-day window is not delayed N days, it is delayed a quarter.
Also worth recording: GitHub Actions does not support `semver-major-days`
at all, so a cooldown could never have applied to the one ecosystem that
stays monthly.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant