python-template
Explanation

How releases work

release-please, the deps commit type, and why the lockfile is synced through the GitHub API

Releases are driven by commit messages. You never edit a version number; you write conventional commits, merge a Release PR, and the tag, changelog and publish follow.

The loop

Every push to main runs release-please. It reads the commits since the last tag, works out the next version, and opens (or updates) a Release PR containing the version bump and the changelog entry. Merging that PR tags the release, which triggers the publish job.

Nothing is released until you merge the Release PR. It is safe to let one sit.

Why deps is a commit type

deps is not part of @commitlint/config-conventional; this project adds it in commitlint.config.mjs and maps it in release-please-config.json.

release-please treats a visible changelog section as releasable. A commit typed deps therefore both appears in the changelog and cuts a patch release — which is what you want when a dependency bump reaches users. chore(deps) is hidden and releases nothing, which is what you want for developer tooling.

.github/dependabot.yml splits accordingly: the Python ecosystem uses prefix: "deps" for production dependencies and prefix-development: "chore(deps)" for dev ones, and the pre-commit, actions and npm ecosystems use chore(deps) throughout. That line — visible or hidden — is the line between "our users are affected" and "our tooling changed".

Why the Release PR is opened by a GitHub App

The workflow mints a short-lived token from a GitHub App rather than using GITHUB_TOKEN.

GitHub does not fire pull_request workflows for PRs opened with GITHUB_TOKEN — a loop-prevention rule. A Release PR opened that way would have no checks at all, so a branch ruleset requiring test could never be satisfied and the PR could never merge.

Why the lockfile is synced through the GraphQL API

release-please rewrites the version in pyproject.toml, but knows nothing about uv.lock, which contains a self-referencing pin of the project at its old version. Left alone, the lockfile is stale on main and the next uv run silently rewrites it.

Three obvious fixes do not work:

  • release-please's extra-files cannot patch it, because uv lock strips comments and the rewrite would not survive.
  • A git commit from the runner is rejected: the default branch ruleset requires signed commits, and no signing key is provisioned on the runner.
  • Passing the lockfile inline to gh api breaks once it exceeds roughly 95KB, hitting the per-argument limit — and gh's @filename expansion does not apply to GraphQL variables.

So sync-lockfile runs uv lock on the Release PR branch and commits the result with the GraphQL createCommitOnBranch mutation. Commits made through the API on an App's behalf are signed by GitHub's own key, which satisfies the signature requirement. The request is assembled with jq --rawfile and submitted via gh api graphql --input, which sidesteps the argument-length limit.

Finding the Release PR

The job locates the Release PR by listing open pull requests and selecting the one authored by the release App, on a release-please--branches-- head branch, carrying the autorelease: pending label — not by release-please's prs output. That output is set only when release-please actually updated a pull request, and it declines to update when the regenerated body is unchanged — so a push carrying only hidden commit types (chore, ci, docs, style, refactor, test, build) reports nothing.

The author is matched on is_bot plus a substring rather than an exact login, because GitHub renders that one identity three different ways — gh pr list --json author gives app/semantic-release-pusher, the REST API gives semantic-release-pusher[bot], and GraphQL's Bot.login gives semantic-release-pusher. Only the first is what the step reads, and an exact comparison against either of the others fails silently.

A same-repo PR on a release-please--branches-- branch that the selector does not match fails the job rather than passing quietly, since that means the selector is wrong rather than that there is nothing to sync.

The selector checks author and branch prefix, not the label alone. A label is mutable by anyone with write access, and the step that follows runs uv lock, which executes build backends out of the branch it checked out — so the label by itself is not a safe way to choose what code to run. The uv lock step deliberately carries no token; only the step that commits, which touches no project code, gets one.

The list is fetched with --limit 200. gh pr list defaults to 30, ordered newest-first, and the Release PR is long-lived — it is the oldest open PR, so it sorts last and would drop off page one on a busy repository, leaving the job to exit green having synced nothing.

It lists pull requests unfiltered and selects locally, rather than passing --label to gh. That flag resolves through GitHub's search API, which is index-lagged and can miss a PR release-please created seconds earlier in the preceding job; the unfiltered listing reads repository.pullRequests directly and is read-your-writes.

The distinction matters when a sync is missed. Gating on prs means that a transient failure is followed by however many pushes it takes for the next feat or fix to arrive, with the Release PR carrying a stale lockfile the whole time. "Is there an open Release PR?" is the precondition the job actually cares about, and asking it that way makes the job idempotent: it re-runs until the lockfile is in sync and exits quietly once it is.

Freshness elsewhere

The relock step unsets UV_FROZEN rather than setting it to 0. Everywhere else it is 1, so the lockfile changes only deliberately — but a step that sets the variable and then runs uv lock --check is asserting with the very thing it is checking: at 1, uv lock no-ops and uv lock --check degrades to a warning, both exiting 0. Unsetting makes the assertion independent of the environment. Locally the same job is done by task uv:lock.

The lint and pytest jobs run uv sync --locked, which fails when uv.lock no longer matches pyproject.toml. The distinction is easy to miss: --frozen installs from the lockfile without checking that it is current, so drift merges silently; --locked asserts freshness and errors. (The bootstrap job is the exception — it re-locks a freshly renamed project, so it runs plain uv sync.)

The uv version comes from tool.uv.required-version in pyproject.toml. setup-uv reads that key, so CI and this job install the same series that mise.toml supplies locally. The pin is to the minor series deliberately: mise withholds releases younger than its minimum_release_age, so a pin to the newest patch resolves to something mise will not install for days, leaving CI green while every local uv command refuses to run.

Publishing

The publish job builds with uv build and uploads via pypa/gh-action-pypi-publish using trusted publishing — OIDC, no API token stored anywhere. It needs a pypi environment on the repository and a matching trusted publisher configured on PyPI. Until both exist, the job fails; a project that will never publish should delete it.

On this page