writingartikelen

release-please bumps your version but forgets uv.lockrelease-please bumpt je versie maar vergeet uv.lock

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.

release-pleaseuvgithub-actionsci-cdpython

← all writing← alle artikelen