release-please bumps your version but forgets uv.lockrelease-please bumpt je versie maar vergeet uv.lock
view on githubbekijk op github
If you version a Python project with release-please and lock dependencies with uv, there’s a small trap that stays hidden until the worst possible moment — the release commit itself.
The problem
release-please’s python release type bumps the version in pyproject.toml (and
CHANGELOG.md, and the manifest). It does not touch uv.lock. But uv writes a
self-referential entry for your own project into the lockfile:
[[package]]
name = "my-project"
version = "0.1.0"
source = { virtual = "." }
That version pin is now stale. So the moment the release PR merges and CI runs
uv sync --locked, it fails:
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
The nasty part: it’s green on every feature PR — no version change there — and only blows up on the release, right when you’re trying to ship. If your deploy is gated on that CI job, nothing goes out.
Two ways out
The honest, simple option is uv sync --frozen instead of --locked. It uses the
lockfile exactly as committed, so it doesn’t care that the version pin drifted — and it’s
already what most Dockerfiles do. You give up two things: the --locked check that
catches “someone edited a dependency but forgot to re-lock” (though uv add/uv remove
keep the lock in sync anyway, and a genuinely missing dependency still fails your tests),
and the lock’s version pin now trails the real version by a release (cosmetic, but it
reads like a bug). For plenty of projects that’s a perfectly reasonable trade — and if you
take it, you can stop reading here.
The catch worth knowing: this drift doesn’t only hit the release commit. Once a release
merges, main has the new pyproject.toml version next to the old uv.lock pin, so the
next feature PR fails uv sync --locked too. So if you want --locked to keep
working anywhere, something has to bump uv.lock when the version bumps. The clean place
to do that is the release PR itself.
The fix: sync uv.lock inside the release PR
release-please tells you when it opened a release PR (prs_created) and on which branch
(pr.headBranchName). So right after the action runs, check out that branch, regenerate
the lock, and commit only the version-pin change back into the PR:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Checkout release PR branch
if: steps.release.outputs.prs_created == 'true'
uses: actions/checkout@v4
with:
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
- name: Set up uv
if: steps.release.outputs.prs_created == 'true'
uses: astral-sh/setup-uv@v6
- name: Sync uv.lock version pin in the release PR
if: steps.release.outputs.prs_created == 'true'
run: |
uv lock
if git diff --quiet -- uv.lock; then
echo "uv.lock already in sync"; exit 0
fi
numstat=$(git diff --numstat -- uv.lock)
read -r added deleted _ <<< "$numstat"
diff_output=$(git diff -U3 -- uv.lock)
if [ "$added" != "1" ] || [ "$deleted" != "1" ] \
|| ! echo "$diff_output" | grep -qE '^\+version = "[0-9]+\.[0-9]+\.[0-9]+"$' \
|| ! echo "$diff_output" | grep -q 'name = "my-project"'; then
echo "::error::uv lock changed more than the version pin — refusing to auto-commit."
echo "$diff_output"; exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add uv.lock
git commit -m "chore: sync uv.lock version pin"
git push
You’ll need permissions: contents: write on the job for that push.
The guard is the important bit
The naive version just runs uv lock && git commit -am … && git push. Don’t. uv lock
can also pull in newer dependency releases any time a >= range resolves to a fresh
publish. If that happens during a release, you’d silently smuggle a dependency bump into
a release PR that’s supposed to be nothing but a version bump.
So the script asserts the diff is exactly one line changed, that the added line is a
version = "x.y.z" pin, and that it sits next to your project’s name. Anything else →
fail the job loudly. A real dependency change then gets its own chore(deps) PR,
where it belongs, instead of hiding inside a release.
The trade-off: when the guard trips, it blocks the release until you land that
chore(deps) PR. In practice it rarely fires — a plain uv lock doesn’t upgrade
already-pinned dependencies, so on a version-only bump the diff really is that one line —
but it’s deliberate strictness, not a free lunch. If you’d rather let dependency bumps ride
along, drop the guard; just know what you’re trading away.
One gotcha about tokens
That step pushes to the release PR branch. The default GITHUB_TOKEN can do that with
contents: write — no extra setup.
But watch out if your deploy runs in a separate workflow triggered by the vX.Y.Z
tag. Pushes and tags made with the default GITHUB_TOKEN do not trigger other
workflows, so your tag would land and nothing would deploy. There you need a GitHub App
token (actions/create-github-app-token) on the release-please step, so the tag counts
as a real actor event.
If instead your deploy lives in the same workflow, gated on release-please’s
release_created output — which fires on the human merge of the release PR — the default
token is fine. That’s the setup in
kobo2readwise.
Result
--locked everywhere again, uv.lock always in sync with the release, and dependency
drift can’t sneak in through the back door. The release PR goes back to being boring —
which is exactly what a release PR should be.
Versioneer je een Python-project met release-please en lock je dependencies met uv, dan zit er een kleine valkuil in die pas op het slechtst denkbare moment tevoorschijn komt — de release-commit zelf.
Het probleem
Het python-release-type van release-please bumpt de versie in pyproject.toml (en
CHANGELOG.md, en het manifest). Het raakt uv.lock niet aan. Maar uv schrijft een
zelf-verwijzende entry voor je eigen project in de lockfile:
[[package]]
name = "my-project"
version = "0.1.0"
source = { virtual = "." }
Die version-pin klopt nu niet meer. Dus op het moment dat de release-PR merget en CI
uv sync --locked draait, faalt het:
error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
Het venijnige: op elke feature-PR is het groen — daar verandert de versie niet — en het klapt pas op de release, precies als je wilt shippen. Zit je deploy achter die CI-job, dan gaat er niets naar buiten.
Twee uitwegen
De eerlijke, simpele optie is uv sync --frozen in plaats van --locked. Die gebruikt
de lockfile precies zoals-ie gecommit is, dus het maakt niet uit dat de version-pin
achterloopt — en het is al wat de meeste Dockerfiles doen. Je levert twee dingen in: de
--locked-check die “iemand wijzigde een dependency maar vergat te herlocken” vangt (al
houden uv add/uv remove de lock sowieso in sync, en een écht ontbrekende dependency
laat je tests alsnog falen), en de version-pin in de lock loopt nu één release achter
(cosmetisch, maar het leest als een bug). Voor veel projecten is dat een prima ruil — en
neem je die, dan kun je hier stoppen met lezen.
Het addertje: die drift raakt niet alleen de release-commit. Zodra een release merget,
staat op main de nieuwe pyproject.toml-versie naast de oude uv.lock-pin, dus de
volgende feature-PR faalt óók op uv sync --locked. Wil je --locked ergens laten
werken, dan móét iets uv.lock meebumpen bij de versie-bump. De nette plek daarvoor is
de release-PR zelf.
De fix: sync uv.lock binnen de release-PR
release-please vertelt je wanneer het een release-PR opende (prs_created) en op welke
branch (pr.headBranchName). Dus meteen na de action: check die branch uit, regenereer de
lock, en commit alleen de version-pin-wijziging terug in de PR:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Checkout release PR branch
if: steps.release.outputs.prs_created == 'true'
uses: actions/checkout@v4
with:
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
- name: Set up uv
if: steps.release.outputs.prs_created == 'true'
uses: astral-sh/setup-uv@v6
- name: Sync uv.lock version pin in the release PR
if: steps.release.outputs.prs_created == 'true'
run: |
uv lock
if git diff --quiet -- uv.lock; then
echo "uv.lock already in sync"; exit 0
fi
numstat=$(git diff --numstat -- uv.lock)
read -r added deleted _ <<< "$numstat"
diff_output=$(git diff -U3 -- uv.lock)
if [ "$added" != "1" ] || [ "$deleted" != "1" ] \
|| ! echo "$diff_output" | grep -qE '^\+version = "[0-9]+\.[0-9]+\.[0-9]+"$' \
|| ! echo "$diff_output" | grep -q 'name = "my-project"'; then
echo "::error::uv lock changed more than the version pin — refusing to auto-commit."
echo "$diff_output"; exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add uv.lock
git commit -m "chore: sync uv.lock version pin"
git push
De job heeft permissions: contents: write nodig voor die push.
De guard is het belangrijkste
De naïeve variant draait gewoon uv lock && git commit -am … && git push. Niet doen.
uv lock kan namelijk óók nieuwere dependency-releases binnentrekken zodra een
>=-range naar een verse publicatie resolvet. Gebeurt dat tijdens een release, dan
smokkel je stiekem een dependency-bump een release-PR in die niets meer dan een
versie-bump zou moeten zijn.
Daarom controleert het script dat de diff exact één regel wijzigt, dat de
toegevoegde regel een version = "x.y.z"-pin is, en dat die naast de name van je
project staat. Alles daarbuiten → laat de job luid falen. Een echte
dependency-wijziging krijgt dan z’n eigen chore(deps)-PR, waar-ie hoort, in plaats van
te verstoppen in een release.
De ruil: als de guard afgaat, blokkeert-ie de release tot je die chore(deps)-PR hebt
gemerged. In de praktijk gebeurt dat zelden — een kale uv lock upgrade’t al gepinde
dependencies niet, dus bij een versie-only bump ís de diff echt die ene regel — maar het
is bewuste strengheid, geen gratis lunch. Wil je dependency-bumps liever laten meeliften,
laat de guard dan weg; weet alleen wat je inlevert.
Eén valkuil met tokens
Die stap pusht naar de release-PR-branch. De standaard GITHUB_TOKEN kan dat met
contents: write — geen extra setup.
Maar let op als je deploy in een aparte workflow draait die op de vX.Y.Z-tag
triggert. Pushes en tags gemaakt met de standaard GITHUB_TOKEN triggeren geen
andere workflows, dus je tag landt en er deployt niets. Dan heb je een GitHub
App-token (actions/create-github-app-token) nodig op de release-please-stap, zodat de
tag als een echte actor-event telt.
Zit je deploy juist in dezelfde workflow, gated op de release_created-output van
release-please — die afgaat bij het menselijke mergen van de release-PR — dan volstaat de
standaard token. Dat is de opzet in
kobo2readwise.
Resultaat
Overal weer --locked, uv.lock altijd in sync met de release, en dependency-drift kan
niet meer via de achterdeur naar binnen. De release-PR wordt weer saai — precies wat een
release-PR hoort te zijn.