Skip to content

🔧 Stop dependabot rewriting manifest ranges it does not need to: versioning-strategy increase-if-necessary for the uv ecosystem - #1942

Merged
chrisjsewell merged 1 commit into
masterfrom
ci/dependabot-lockfile-only
Sep 15, 2026
Merged

chrisjsewell merged 1 commit into
masterfrom
ci/dependabot-lockfile-only

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

One line in .github/dependabot.yml: versioning-strategy: increase-if-necessary for the uv ecosystem, with the reason written next to it. (First opened as lockfile-only; changed after weighing the cost below.)

Why

Dependabot's default strategy for a project with a lockfile is increase: it rewrites the manifest requirement to match the new release even when the requirement already allows it. Every specifier in this workspace's manifests is a decision, so that is the wrong strategy here. Floors are series (ty~=0.0.80, types-docutils~=0.20.0), caps carry their reason as a comment, and the intra-workspace floors are moved by propagate_floors.py at release time.

The 2026-09 batch, #1940, is the measurement. As proposed it raised sphinx-needs' matplotlib>=3.3.0, the floor of the user-facing plotting extra, to >=3.11.2, the newest release, although the floor already allowed it. An earlier batch rewrote ~=0.20.0 as >=0.20,<0.24, which is why the types-docutils ignore rule exists.

What changes

With increase-if-necessary, a requirement that already allows the new release is left alone and only the lock moves. A release outside a range still produces a pull request that widens the range.

That second half is kept on purpose. 44 of the 97 external specifiers in these manifests carry a cap or a series, many of them runtime caps on sphinx-needs' user-facing dependencies (jsonschema-rs<0.54, requests-file<4, pydata-sphinx-theme<0.20, sphinx-immaterial<0.14, pytest<10, ...). The widening pull request is the one signal that a new major of one of them exists, and its review is where a stale reason comment or a widened ~= series gets re-expressed by hand. lockfile-only was considered and rejected for exactly this: it never edits a manifest, but it also never proposes such a release, so the workspace would sit on an old series until a user noticed.

The ignore rules stay and are unaffected: they stop lock-only updates that the manifests would allow but the test matrix and the typing floor do not want (a sphinx minor inside >=7.4,<10, a types-docutils minor inside its series). The member-name ignores stay for their own reason (dependabot-core#14004).

Applied to last night's batch

Under this strategy #1940 would have been: matplotlib and uv as lock-only updates, and the click bump as a widening of click < 8.2 for review. That cap turned out to pin a package nothing uses, and #1943 drops it deliberately.

Caveat

The options reference lists uv as supporting versioning-strategy and lists the values for the option as a whole; it notes an ecosystem may not support every value. If uv does not accept this one, dependabot rejects the configuration visibly on its settings page rather than doing anything silently, and lockfile-only is the fallback.

Checked

uv run poe lint 8/8 (yamlfmt included). No lock change: the file is configuration only.

@github-actions github-actions Bot added the pkg: workspace The repository as a whole: workflows, CI, release, docker, tooling, the workspace root label Sep 15, 2026
@codecov

codecov Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.70%. Comparing base (d68d10d) to head (0eb1c2e).
⚠️ Report is 4 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #1942   +/-   ##
=======================================
  Coverage   91.69%   91.70%           
=======================================
  Files         129      129           
  Lines       18155    18152    -3     
=======================================
- Hits        16648    16647    -1     
+ Misses       1507     1505    -2     
Flag Coverage Δ
codelinks 93.42% <ø> (ø)
mounts 94.03% <ø> (+0.01%) ⬆️
pytests 91.42% <ø> (+0.01%) ⬆️
reports 88.75% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…ioning-strategy increase-if-necessary for the uv ecosystem

Dependabot's default strategy for a project with a lockfile is `increase`: it
rewrites the manifest requirement to match the new release even when the
requirement already allows it. Every specifier in this workspace's manifests is
a decision -- floors are series, caps carry their reason as a comment,
intra-workspace floors are moved by `propagate_floors.py` at release time --
so that is the wrong strategy here, and the 2026-09 batch (#1940) showed it:
it raised sphinx-needs' `matplotlib>=3.3.0`, the floor of a user-facing extra
that already allowed 3.11.2, to `>=3.11.2`. An earlier batch rewrote
`~=0.20.0` as `>=0.20,<0.24`.

`increase-if-necessary` leaves a requirement alone when it already allows the
new release, so only the lock moves, and widens it only when a release falls
outside the range. That widening pull request is kept on purpose: 44 of the 97
external specifiers here carry a cap or a series, and the pull request is the
one signal that a new major of one of them exists; its review is where a stale
reason comment or a widened series is re-expressed by hand. `lockfile-only`
was considered and rejected for that reason -- it never edits a manifest, but
it also never proposes such a release. The `ignore` rules are unaffected.

The option is supported for the uv ecosystem (dependabot options reference,
"Supported by: bundler, cargo, composer, helm, mix, npm, pip, pub, and uv").
@chrisjsewell
chrisjsewell force-pushed the ci/dependabot-lockfile-only branch from c444029 to 0eb1c2e Compare September 15, 2026 22:06
@chrisjsewell chrisjsewell changed the title 🔧 Stop dependabot editing manifests: versioning-strategy lockfile-only for the uv ecosystem 🔧 Stop dependabot rewriting manifest ranges it does not need to: versioning-strategy increase-if-necessary for the uv ecosystem Sep 15, 2026
@chrisjsewell
chrisjsewell merged commit 0aa2e95 into master Sep 15, 2026
39 checks passed
@chrisjsewell
chrisjsewell deleted the ci/dependabot-lockfile-only branch September 15, 2026 22:22
chrisjsewell added a commit that referenced this pull request Sep 16, 2026
…ten down (#1944)

Reverts #1942's `versioning-strategy: increase-if-necessary` and
records, in two places, what was measured and what a reviewer does
instead.

### What went wrong with #1942

After the merge, every dependabot job for this repository errored with
"Dependabot can't resolve your Python dependency files":

```
No solution found when resolving dependencies for split (… included: sphinx-needs-workspace[group:typing] …):
  Because sphinx>=7.4.0,<=7.4.7 depends on docutils>=0.20,<0.22 and
  sphinx-needs-workspace:typing depends on docutils==0.23, …
```

The mechanism, from dependabot-core's python update checker (which uv
reuses): every strategy except `lockfile-only` *unlocks* a requirement
before asking uv for the newest resolvable version. For the `typing`
group's `docutils~=0.20.0` that asks uv to resolve docutils 0.23 beside
`sphinx~=7.4`, which forbids anything above 0.21. Under the default
strategy that probe degrades to "no update for docutils" (which is why
the 2026-09 batch, #1940, came through with three updates and no
docutils); under `increase-if-necessary` it is fatal to the whole job.
Both refreshes of #1940 after the merge failed that way, and the run
triggered by the config change proposed nothing. Net effect: no
dependabot updates of any kind.

`lockfile-only` was the other candidate. It never unlocks, so it cannot
hit this, and it never edits a manifest, but it also never proposes a
release outside a range. 44 of the 97 external specifiers in these
manifests carry a cap or a series, and the widening pull request is the
one signal that a new major of one of them exists. Not worth the
silence.

### What this does instead

- `.github/dependabot.yml`: the option is removed, and the comment
beside the uv block records both measurements and why the default stays.
- `AGENTS.md`: one paragraph under *Pull request requirements* telling a
reviewer of a dependabot uv pull request to read the `pyproject.toml`
hunks, not only the lock; what the default strategy does to floors, caps
and `~=` series, with #1940's three cases as the examples; and the
procedure: keep the lock update, restore the specifier lines by hand on
the dependabot branch, relock, and give a cap or series that really
should move its own pull request (#1943 is the shape).

### After the merge

Dependabot's run triggered by this config change should complete green
on the Insights page, which is the proof the jobs are back. #1940 then
gets its specifier lines restored by hand and is merged as the lock
update it should have been; #1943 drops the click cap on its own
reasoning.

### Checked

`uv run poe lint` 8/8 (yamlfmt, taplo, check-readme included). No lock
change.
chrisjsewell added a commit that referenced this pull request Sep 16, 2026
… fails every uv job (#1946)

Adds `docutils` to the uv ecosystem's ignore list for major and minor
updates, beside `types-docutils`, and corrects a wrong claim #1944 made.

### The error, and what it is not

Every rebase or recreate of #1940 errored with "Dependabot can't resolve
your Python dependency files", and so did the rebase job that ran after
#1944 reverted `versioning-strategy`, under the default:

```
Because sphinx>=7.4.0,<=7.4.7 depends on docutils>=0.20,<0.22 and
sphinx-needs-workspace:typing depends on docutils==0.23, …
```

#1944 attributed the error to `increase-if-necessary`. That was wrong:
the same job fails under the default strategy. The comment in
`dependabot.yml` and the paragraph in `AGENTS.md` now say so where the
claim was made.

### What it is

docutils 0.23 has been out since May. What changed is #1871:
sphinx-codelinks arrived with a bare `docutils` dependency, so docutils
became a *direct, unpinned* dependency in dependabot's eyes, and
dependabot now tries to lock 0.23. It does that by writing
`docutils==0.23` on every requirement line it finds, including the
`typing` group's `docutils~=0.20.0`, and uv correctly refuses that
beside `sphinx~=7.4`, which needs docutils below 0.22. The lock already
carries docutils at 0.20.1 in the typing split and 0.21.2 everywhere
else, which is exactly what `[tool.uv] conflicts` provides; dependabot
has no way to express "0.20.x here, newest elsewhere", and the uv
updater treats uv's refusal as fatal to the whole job. Group refreshes
before #1871 (the 5 September batch, #1851) rebased fine.

### The fix

docutils joins `types-docutils` under the same ignore: major and minor
updates ignored, patches inside the series still flow. The earlier
rationale for leaving docutils unlisted, keeping new series flowing to
the main environment, never operated: dependabot only moves a lock entry
for a direct dependency, and docutils was not one until #1871, so the
default environment sat on 0.21.2 with 0.23 out. It moves by hand now,
with `uv lock --upgrade-package docutils`, the way the typing series
itself does.

### Consequences for the strategy question

`increase-if-necessary` (#1942) was reverted for a reason that turns out
not to hold, so it is untested rather than unusable. The comment records
it as a candidate to retry once this rule has been seen to keep the jobs
green; that is a separate decision and a separate pull request.

### Proof after the merge

The dependabot run this config change triggers should complete green on
the Insights page, where the red "Rebase update" entries are. There is
no open dependabot PR to refresh right now, so that run is the evidence.

### Checked

`uv run poe lint` 8/8. No lock change.
chrisjsewell added a commit that referenced this pull request Sep 16, 2026
…#1943)

Removes one line from `packages/sphinx-codelinks/pyproject.toml`,
`"click < 8.2"`, with a changelog bullet, and relocks. click leaves the
lock entirely.

### Why the cap existed, and why it is dead

The line's comment said: *click 8.2.* produces empty errors if no args
are given*. That was click 8.2.0 against the typer of the time, and the
cap kept `codelinks` printing its usage panel when run bare.

Two facts make it dead weight now, both measured on this branch:

- this package never imports click itself (`git grep` over `src/`:
nothing);
- the typer it requires, 0.27.2 in the lock, depends on annotated-doc,
colorama, rich and shellingham, and not on click.

So the cap was the *only* reason click was installed at all, and `uv
lock` drops click from the lock once the line goes.

### Checked

In the synced environment, with click no longer importable:

- `codelinks` with no arguments prints its usage panel and exits 2,
exactly as before;
- `codelinks nonsense` prints the "No such command" error panel, exactly
as before;
- `uv run poe test-codelinks`: 360 passed, 58 snapshots;
- `uv run poe lint`: 8/8.

### Context

Dependabot's 2026-09 batch (#1940) proposed lifting the cap to `<8.6`
past the comment explaining it, which is what prompted re-checking
whether the cap still had a reason. #1942 stops dependabot rewriting
manifest specifiers like this in future; the deliberate lift is this
pull request. Precedent for the shape: #1896, the typer cap.
chrisjsewell added a commit that referenced this pull request Sep 17, 2026
…pgrade` job (#1951)

### Why

Dependabot's uv updater cannot update this lock, for two measured
reasons.

**It pins one candidate version on every requirement line before it
relocks.** This
workspace legitimately locks some packages at two versions across its
`[tool.uv] conflicts`
splits — `docutils` 0.20.1 in the `typing` group beside the newest
elsewhere,
`myst-parser` 4.x in the `sphinx-7` cell beside 5.x elsewhere. uv
refuses the universal
pin, and the updater treats the refusal as fatal to the whole job: every
group refresh
died with "Dependabot can't resolve your Python dependency files".

**Its default `versioning-strategy` rewrites manifest specifiers it has
no reason to
touch.** #1940 raised `matplotlib>=3.3.0` to `>=3.11.2` on a user-facing
extra whose floor
already allowed the release, and widened `click < 8.2` past the comment
explaining the cap.

Three merged pull requests (#1942, #1944, #1946) and a fourth (#1948,
six more ignore rules,
closed unmerged in favour of this) went into working around that.
Meanwhile `uv lock --upgrade` does the job in
one command: on master today it resolves cleanly across every split and
moves 13 packages,
including `docutils v0.20.1, v0.21.2 -> v0.20.1, v0.22.4` — the exact
thing dependabot
cannot express — and it never touches a manifest.

### What this does

Adds `.github/workflows/uv-update.yaml` — `UV update`, monthly on `0 7 1
* *` (an hour
after `Prek update`, so both land on the same morning and neither
depends on the other)
plus `workflow_dispatch` — modelled step for step on `prek-update.yaml`:
the bot-app token,
`actions/checkout@v7`, `astral-sh/setup-uv@v10.1.0` on the root
`.python-version`, and one
pull request from the fixed branch `uv-update` titled `⬆️ Update the uv
lock (monthly)`.

The steps: `uv lock --upgrade`, capturing uv's lock-diff lines (stderr,
so `2>&1` into
`$RUNNER_TEMP`); an assertion that **no manifest changed**, which fails
the job loudly if
one did; `uv sync --frozen` and `uv run --frozen prek run --all-files`,
recording whether
the hooks came out clean; the held-back list; and the composed body.

The body reports **all three of uv's lock-diff verbs**, not just
`Updated`. `Added` and
`Removed` get bullets of their own — a package entering the dependency
graph is the most
review-worthy line a lock update can carry, and a body that drops it
silently is worse than
one that says nothing — and any other `<Verb> <name> v<version>` line uv
may invent is
printed as `unrecognised uv line:` rather than discarded, so the body
announces a new verb
instead of quietly shrinking. Measured on a real 18-line upgrade log: 18
lines in, 18
bullets out.

Then `.github/dependabot.yml` loses its uv section — ignore rules, `ty`
group and all —
and gains ten lines saying why. `AGENTS.md`'s dependabot-review
paragraph is replaced by
one paragraph on the job, the same length as the one it replaces, with
the history left in
`dependabot.yml` where it belongs.
`tools/tests/test_dependabot_config.py` fences the
configuration so the uv entry cannot come back, and fences the
replacement *as a command*
in the `update` step — `uv lock --upgrade` also appears in prose in the
same file, so a
substring check would stay green while the job was gutted to a no-op
relock.

### What stays with dependabot

The **`github-actions`** ecosystem, untouched: that half has never had a
problem here.
And **security alerts and security-update pull requests**, which are a
repository feature
reading `uv.lock` independently of `dependabot.yml`'s version-update
config — removing the
uv section does not stop them.

### Two decisions, with their measurements

**No cooldown**, unlike `prek update --cooldown-days 7`. `uv lock
--upgrade --exclude-newer`
is a *global index cutoff*, not a per-package delay, and it failed both
halves of the test:
at seven days it made the lock unresolvable (`Because only ty<=0.0.79 is
available and
sphinx-needs-workspace:typing depends on ty>=0.0.80,<0.1.dev0 …` — the
`typing` group's own
floor is younger than the window, and so would be any floor a release
bump had just
raised); and with a cutoff recent enough to resolve, it wrote `[options]
exclude-newer` into
`uv.lock`, after which a plain `uv lock --check` reported "The lockfile
at `uv.lock` needs
to be updated" and the `uv-lock` prek hook rewrote the lock and failed —
on the job's own
pull request. The workflow carries a comment saying so. Dependabot had
no cooldown here
either, so nothing is lost.

**The held-back list comes from `uv tree --outdated --frozen --depth
1`**, run after the
upgrade. `uv pip list --outdated` was tried first and cannot do it: it
reads one installed
environment, so `pydata-sphinx-theme` (behind the `theme-pds` extra) is
invisible to it —
as is every conflicting group. `uv tree` reads the lock, `--depth 1` is
each member's own
requirements, and running it after the upgrade leaves only what is held
back rather than
what was merely pending. Scored against the known positives: it flags
`jsonschema-rs`
(0.53.0, capped `<0.54.0`) and `pydata-sphinx-theme` (0.17.1, capped
`<0.20.0`), and
correctly does *not* flag `comment-parser` (already newest wherever
installable) or
`myst-parser` (already 5.1.0 by default; 4.0.1 survives only in the
`sphinx-7` and `typing`
cells, held by `sphinx~=7.4`). A grep over the manifests puts the
specifier beside the
versions — and the body says plainly what that column is: every
specifier this workspace
writes for that name, while what actually blocks the newer release may
be one of those, a
cap elsewhere in the graph, or a `[tool.uv] conflicts` split, with
`uv lock --upgrade-package <name>` as the way to find out. The list is
the **default
resolution's** view; a package held back only in a matrix split is not
in it, and the body
says that too.

### Proving it without merging it

A `workflow_dispatch` needs the file on the default branch, so the five
`run:` blocks were
extracted from the YAML *verbatim* (parsed with pyyaml, the way
`tools/tests/test_ci_plantuml_steps.py` reads workflow YAML) and run
locally under
`bash -e` with the runner's environment variables. Three runs:

* **on today's lock** — `changed=true`, the 13 `Updated` lines captured,
manifests
untouched, all eight hooks Passed against the upgraded lock (ty
included), the held-back
  list carrying both known positives, and the body composed in full;
* **with the lock already current** — `changed=false`, "no lock updates
available; nothing
to open a pull request for", and every later step skipped by its `if:`;
* **with a floor edited by hand** (dependabot's own `matplotlib>=3.3.0`
→ `>=3.11.2`) — the
assert step fails with `::error::uv lock --upgrade edited a manifest;
this job only ever
  moves uv.lock` and `exit 1`.

Plus, for the body's new verbs: a constructed log covering
`Updated`/`Added`/`Removed`/an
unknown verb, and a real 18-line upgrade log rebuilt from a two-week-old
`uv.lock`. And for
the fence: weakening the update step's `uv lock --upgrade` to `uv lock`
turns the test red.

`uv.lock` is deliberately **not** part of this pull request; the tree
was returned to clean
before anything was committed.

### After the merge

Dispatch `UV update` once by hand as the rehearsal; it opens the first
real pull request.
Then delete the `ci/dependabot-ignore-unlockable` branch, kept on origin
as the fallback
until this landed.

### Review

Built by one agent against a binding brief, reviewed by a second that
re-ran every proof from its own extraction of the YAML and added 15
mutations of its own; its three findings (the dropped `Added`/`Removed`
lines, the fence matching prose, the misleading "no upper bound"
sentence) are the fix round folded in above, and it re-verified the two
mechanisms afterwards (the invented verbs `Downgraded`/`Yanked` come out
loud, five uv summary shapes stay ignored; the fence is red for a
weakened command and green for weakened prose). Two constructed, not
observed, residuals remain and are accepted: a version *pair* on an
`Added`/`Removed` line would malform that bullet's PyPI link (the
package is still named), and a hyphenated verb would slip the catch-all.
Gates re-run by hand on the final head: `uv run poe lint` 8/8, `pytest
tools/tests` 316 passed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: workspace The repository as a whole: workflows, CI, release, docker, tooling, the workspace root

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant