diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 13a8bfcd..fc4f7108 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,6 +5,44 @@ # dependency bot stops being read. Each ecosystem instead gets one grouped pull # request a month. # +# That grouping is only safe for updates that promise to be compatible, so each +# group is limited to `minor` and `patch`. Majors fall outside every group and +# arrive as one pull request per dependency, each carrying its own changelog -- +# this 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. Note that it is `update-types` alone that does this: +# suppressing majors outright takes an additional `ignore` condition on +# `version-update:semver-major`, which is deliberately not written here. +# +# The first run without that limit produced an eleven-package npm batch that +# crossed TypeScript 5 -> 7 and Vite 6 -> 8 (#477), and a seventeen-crate cargo +# batch that crossed tauri-plugin-prevent-default 2 -> 5 (#478) -- toolchain +# migrations under a `chore(deps)` title, which is not a thing anyone can review +# as a batch. +# +# One caveat, 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 looks at `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 -- but they are deferred. +# +# One thing no `update-types` setting can fix, because it is not about semver: +# the Tauri plugins ship as coupled pairs, one crate and one npm package, and +# `tauri build` refuses to run when the two halves disagree -- +# +# 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) +# +# npm and cargo are separate ecosystems to Dependabot, so the two halves land in +# two different pull requests that cannot see each other, and neither is green +# on its own. The guard compares major *and minor*, so grouping by minor and +# patch does not avoid it. When a Tauri plugin appears in both the npm and the +# cargo pull request, the two have to be merged as a pair -- merge one, then +# rebase and merge the other, and treat the red build on the first as expected +# rather than as a reason to close it. +# # Security updates are not configured here -- they are a repository setting # (Settings -> Advanced Security -> Dependabot security updates) and they open # their own pull requests as advisories land, independent of the schedule below. @@ -19,14 +57,20 @@ updates: directory: / schedule: interval: monthly - # One group covers everything, so in practice this is 1. The headroom is - # for the case where a grouped pull request is open and stale. - open-pull-requests-limit: 2 + # One slot for the grouped minor/patch pull request, three for the majors + # that now open individually alongside it. At the old limit of 2 a single + # major filled the file and the rest were invisible. Anything past the limit + # waits for a later run rather than being skipped, so this is how many stay + # visible at once, not a cap on how many are pending. + open-pull-requests-limit: 4 groups: npm: applies-to: version-updates patterns: - '*' + update-types: + - minor + - patch commit-message: prefix: chore prefix-development: chore @@ -37,28 +81,37 @@ updates: directory: /src-tauri schedule: interval: monthly - open-pull-requests-limit: 2 + open-pull-requests-limit: 4 groups: cargo: applies-to: version-updates patterns: - '*' + update-types: + - minor + - patch commit-message: prefix: chore include: scope # .github/workflows/*.yml. Four of the five actions in use are tracked by - # major tag, so this moves once or twice a year. + # major tag (`@v7`, `@v3`; the fifth is `dtolnay/rust-toolchain@stable`, which + # carries no version for Dependabot to compare), so a bump here is usually the + # tag moving to a new major -- exactly the case the group excludes. In + # practice this ecosystem will open single pull requests rather than a batch. - package-ecosystem: github-actions directory: / schedule: interval: monthly - open-pull-requests-limit: 2 + open-pull-requests-limit: 4 groups: github-actions: applies-to: version-updates patterns: - '*' + update-types: + - minor + - patch commit-message: prefix: ci include: scope diff --git a/scripts/dependabotConfig.test.ts b/scripts/dependabotConfig.test.ts new file mode 100644 index 00000000..45fbd221 --- /dev/null +++ b/scripts/dependabotConfig.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { readSource } from './sourceTree.js'; + +// What this file locks, and why it is not visible from reading the config once. +// +// Dependabot's own schema check accepts a config that groups majors, and the +// consequence only appears a month later as a pull request nobody can review: +// the first run after #472 batched eleven npm packages across TypeScript 5 -> 7 +// and Vite 6 -> 8 (#477), and seventeen crates across +// tauri-plugin-prevent-default 2 -> 5 (#478). Nothing failed. It just produced a +// `chore(deps)` title wrapped around a toolchain migration. +// +// Blocks are checked one at a time rather than by searching the whole file, so +// that a fourth ecosystem added without the same limits fails here instead of +// passing because the other three still match. +// +// `ecosystemBlocks` asserts it found something because every check below is a +// `for` over its result, and a `for` over an empty list passes. Measured: an +// extra space after the `-` in each `- package-ecosystem:` line left the four +// property checks green and only the coverage check red. That is the shape +// where a guard and the thing it guards share one detector, so the detector +// states its own precondition rather than leaving it to a sibling test. + +const config = readSource('.github/dependabot.yml'); + +/** + * The prose above `version: 2` — the argument that justifies the settings below + * it — as one line, with the `#` markers and the wrapping removed. + * + * Unwrapped because a sentence in a YAML comment is broken across lines at + * whatever column it reaches, so `/one grouped pull request a month/` against + * the raw text is a claim about where the author happened to press enter. It + * did not match: the phrase is split after "pull". + */ +const header = config + .slice(0, config.indexOf('version: 2')) + .replace(/^#[ \t]?/gm, '') + .replace(/\s+/g, ' '); + +type EcosystemBlock = { ecosystem: string; text: string }; + +function ecosystemBlocks(): EcosystemBlock[] { + const blocks: EcosystemBlock[] = []; + const starts: number[] = []; + const marker = /^ {2}- package-ecosystem: (\S+)$/gm; + const names: string[] = []; + for (let m = marker.exec(config); m; m = marker.exec(config)) { + starts.push(m.index); + names.push(m[1]); + } + assert.ok( + starts.length > 0, + 'found no `- package-ecosystem:` block in .github/dependabot.yml; every check in this ' + + 'file iterates over these blocks, so an unparsed file passes all of them silently', + ); + for (let i = 0; i < starts.length; i += 1) { + blocks.push({ + ecosystem: names[i], + text: config.slice(starts[i], starts[i + 1] ?? config.length), + }); + } + return blocks; +} + +test('every ecosystem the repository has is configured', () => { + const found = ecosystemBlocks().map((b) => b.ecosystem); + // Sorted so the assertion is about coverage rather than about ordering. + assert.deepEqual([...found].sort(), ['cargo', 'github-actions', 'npm']); +}); + +test('no group may batch a major version', () => { + for (const { ecosystem, text } of ecosystemBlocks()) { + assert.match( + text, + /update-types:\n\s+- minor\n\s+- patch\n/, + `the ${ecosystem} group must limit itself to minor and patch; without that, ` + + 'a major lands inside a grouped pull request and stops being reviewable', + ); + assert.doesNotMatch( + text, + /^\s+- major$/m, + `the ${ecosystem} group must not list major among its update types`, + ); + } +}); + +test('a security fix never waits for the monthly batch', () => { + for (const { ecosystem, text } of ecosystemBlocks()) { + // Without this, Dependabot folds security updates into the grouped pull + // request, and an advisory published on the 2nd waits until the 1st. + assert.match( + text, + /applies-to: version-updates/, + `the ${ecosystem} group must apply to version updates only`, + ); + } +}); + +test('the schedule is the cadence the header comment promises', () => { + // Two copies of one fact: the prose that argues for a quiet bot, and the + // `interval:` that implements it. Asserting `interval: monthly` on its own + // would be a constant checking itself — the header could be edited to + // promise a weekly bot and nothing would notice. So the expected value is + // read out of the promise. + const promised = /one grouped pull request a (day|week|month)\b/.exec(header); + assert.ok( + promised, + 'the header comment must still state the cadence it is asking a maintainer to accept', + ); + const interval = { day: 'daily', week: 'weekly', month: 'monthly' }[promised[1]]; + for (const { ecosystem, text } of ecosystemBlocks()) { + assert.match( + text, + new RegExp(`interval: ${interval}\\b`), + `the header comment promises one grouped pull request a ${promised[1]}, so ${ecosystem} ` + + `must be on \`interval: ${interval}\``, + ); + } +}); + +test('the open pull request limit leaves room for majors to queue', () => { + for (const { ecosystem, text } of ecosystemBlocks()) { + const limit = /open-pull-requests-limit: (\d+)/.exec(text); + assert.ok(limit, `${ecosystem} must state an open-pull-requests-limit`); + // One slot is spent on the grouped minor/patch pull request. A limit of 2 + // leaves exactly one for majors, which is what made the backlog invisible. + assert.ok( + Number(limit[1]) >= 3, + `${ecosystem} has a limit of ${limit[1]}; majors open one pull request each, ` + + 'so anything under 3 hides the queue rather than shortening it', + ); + } +});