Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 59 additions & 6 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
135 changes: 135 additions & 0 deletions scripts/dependabotConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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',
);
}
});