Releasing
OpenRAL ships one lockstep SemVer version across the whole workspace: the
root pyproject.toml and all 13 python/* packages carry the same number, and
every openral-* dependency pins it exactly. A release is a single v*.*.*
tag that publishes all 13 python/* distributions to PyPI together. Nobody picks the
number by hand: it is derived from the Conventional Commits merged since the
last tag.
The loop
merge PRs to master → release-please computes the bump → release PR
│
release-pypi.yml ← tag v0.3.0 ← merge the release PR
│
└→ 13 packages on PyPI (Trusted Publishing, OIDC)
- Every push to
masterre-runsrelease-please.yml. It reads the commits since the tag recorded in.release-please-manifest.jsonand picks the segment:
| Commit | Bump while < 1.0 |
Bump at >= 1.0 |
|---|---|---|
fix: … |
patch (0.2.0 → 0.2.1) |
patch |
feat: … |
minor (0.2.0 → 0.3.0) |
minor |
feat!: … / BREAKING CHANGE: |
minor (bump-minor-pre-major) |
major |
docs/test/style/ci/build/chore |
none | none |
The highest-ranking commit in the range wins. A range containing only hidden types produces no release PR at all.
- The release PR (
chore(release): vX.Y.Z) is the human gate. It rewrites every version and everyopenral-*==pin, and regeneratesCHANGELOG.md. Review the number and the notes there; the changelog body is editable, so curate it in the PR if a release deserves a narrative.
It also carries a second commit, chore(release): refresh uv.lock for the
version bump, pushed by the same workflow. release-please only substitutes
text in the files extra-files names, but the generated uv.lock records
every workspace member's version; a bump that stops at the pyprojects leaves
the lock a release behind and uv sync --frozen installs stale metadata.
The step runs uv lock on the release branch; without --upgrade it
rewrites only the 14 workspace version lines and leaves third-party pins alone.
test_lockstep_versions.py fails if the lock and the tree ever disagree
again.
Its CI is deliberately thin. test-selective short-circuits on the
release-please--* branch and selects nothing: rewriting all 14 pyprojects
matches the pyproject.toml full-run glob and marks every package changed,
so the selector would otherwise run the entire suite plus every opt-in
dependency lane (including isaacsim / robotwin / gr00t, whose sidecars a
hosted runner cannot provision) for a diff that cannot change behaviour.
Every commit the release covers already passed on its own PR. The job still
runs and reports green, because it is a required check and a skipped
required check is never reported. quality and DCO are
unaffected, and release-pypi.yml re-runs the full precheck gate against
the tag before anything reaches an index.
- Merging it tags
vX.Y.Zand publishes the GitHub release. The tag push triggersrelease-pypi.yml, which runs the same fast quality gate as PR CI and then publishes each package via PyPI Trusted Publishing.
Check the release PR against git log
release-please expands a squash-merge body: GitHub composes it from every
sub-commit, and each * type(scope): subject line is parsed as its own
entry. When the body fails the conventional-commits grammar (one prose line
containing source syntax is enough), the expansion is dropped silently. The
commit's own subject still lands, so the failure looks like a normal one-line
entry. A large squash can lose dozens of sub-commits this way. One body failed
to parse on a line like:
isinstance(x, (int, float)) admits bool
The changelog then showed only the squash commit's own subject, and the dropped
sub-commits' BREAKING CHANGE: footer went with them: a release can end up with
no "⚠ BREAKING CHANGES" section even though it removes a public contract, and
nothing warns you.
So before merging a release PR, diff its changelog against the range it covers:
git log --no-merges --format='%s' v<last>..origin/master
Anything in that list with a user-facing type (feat, fix, refactor,
perf) that is absent from the PR's changelog was dropped; add it by hand.
Check large squashes line by line, and check for a missing "⚠ BREAKING
CHANGES" section against git log --grep='BREAKING CHANGE'. The changelog
body is editable, and editing it is the intended fix; the merged commit
message cannot be corrected without rewriting master.
A merge commit needs the same care in the other direction: keep its message
free of any type(scope): subject line, or release-please parses the merge
and the branch commit and the entry appears twice.
Two habits keep this rare: prefer several focused PRs over one very large squash, and keep code snippets out of commit bodies (describe the fix in prose, or fence the snippet in a PR comment instead).
When release-please hits a GitHub 5xx
Each run pages every commit since the last tag through GitHub's GraphQL API —
dozens of requests once a few hundred commits are unreleased — and a single
5xx (an HTML "Unicorn!" page in the log, after release-please failed:) fails
the run. The workflow retries the action once after 60 s, with a ::warning::
saying so; a second failure fails the job. If it still fails, re-run it from
the Actions tab, or push anything to master. Cutting a release shortens the
walk, which makes this rarer.
When a release stalls between merge and tag
release-please can find a merged release PR it cannot build a release from and
then decline to open a new one, logging There are untagged, merged release
PRs outstanding - aborting. It emits no outputs in that state, byte-for-byte
what "nothing releasable merged" looks like: no tag, nothing publishes, no
warning.
The summarise step in release-please.yml
tells them apart by asking the repository rather than the action: a merged
PR still labelled autorelease: pending is a release that stopped between
merging and tagging, and the job fails loudly.
The reason is in the same job's log above the abort, on a line naming what could not be built. The one that bit us was:
⚠ PR component: undefined does not match configured component: openral
getBranchComponent() derives a component from package-name and, unlike
getComponent(), ignores include-component-in-tag; the release branch has no
component segment, so the two could never match. Removing package-name fixed
it. Once the config is right, a re-run tags the already-merged PR; it does not
need to be reopened or remade.
What you have to do
Write Conventional Commits (already required, CLAUDE.md §4.2). That is the whole contract. Two escape hatches exist for the rare case where the computed number is wrong:
- Force a specific version — put
Release-As: 0.4.0in a commit footer. - Force a bump from an otherwise-silent change — use
feat:deliberately, or add aBREAKING CHANGE:footer describing the incompatibility.
Do not hand-edit a version = field to cut a release. Versions and pins
are rewritten by release-please via the x-release-please-version /
x-release-please-start-version annotations in each pyproject.toml; editing
around them puts the manifest and the tree out of sync.
Nothing under src/ carries a version either: every package's __version__
reads importlib.metadata for its own distribution. (Hardcoded literals in
eleven __init__.py files once let a wheel announce a stale version.)
test_lockstep_versions.py rejects a reintroduced literal.
Adding a package
A new python/<name> package needs three edits, all enforced by
tests/unit/test_lockstep_versions.py:
- Its
pyproject.tomlstarts at the current lockstep version, with# x-release-please-versionon theversion =line and itsopenral-*deps grouped in anx-release-please-start-version/x-release-please-endblock, pinned==<current version>. - Add
python/<name>/pyproject.tomltoextra-filesinrelease-please-config.json. - Add
python/<name>to the publish matrix inrelease-pypi.yml.
Token setup (one time)
GitHub does not trigger workflows from events created with the default
GITHUB_TOKEN. Without a dedicated token, release-please's tag lands but
release-pypi.yml never fires.
Set the RELEASE_PLEASE_TOKEN repository secret to a GitHub App
installation token (preferred) or a fine-grained PAT with contents:write
and pull-requests:write. This also makes the release PR run the required
quality and DCO checks normally, so it merges without an admin bypass.
Without the secret the job still runs and says so with a ::warning::. Recover
a stranded tag by dispatching release-pypi.yml manually with target=pypi,
confirm=YES.
Trial runs
release-pypi.yml accepts a workflow_dispatch with target=testpypi (the
default), which publishes to TestPyPI without confirmation. It validates
packaging without burning a version number (skip-existing makes a re-run
against an already-published version a no-op).
Do this before any release that changes packaging metadata; it is the only way to see what installers will read. Then check the published requirements, not the source:
curl -s https://test.pypi.org/pypi/openral-cli/<version>/json \
| python3 -c "import sys,json;[print(r) for r in json.load(sys.stdin)['info']['requires_dist']]"
A trial run should confirm every openral-* sibling carries its == pin,
with extras and markers intact (e.g. openral-observability[dashboard]==0.2.0,
pygobject>=3.42; extra == "gstreamer"). Neither uv lock --check nor the
unit tests can show that: they read source, and [tool.uv.sources] is stripped
at build time.
When the token exchange fails
A invalid-publisher: valid token, but no corresponding publisher error means
the OIDC claims match no registered trusted publisher; nothing is uploaded. Register one per project with owner: OpenRAL, repo: openral,
workflow: release-pypi.yml, and no environment name (the workflow
declares none, so the claim is environment: MISSING and a publisher that
expects one will never match).
Trusted publishing also matches on the ref, so claims are not portable between
triggers: a tag push presents refs/tags/vX.Y.Z, a dispatch off master
presents refs/heads/master. A publisher with no ref restriction accepts
both; one restricted to tags rejects the dispatch. A green tag release
therefore does not imply a green dispatch, and vice versa.
What is not versioned by this
schema_version on the on-disk Pydantic schemas is a separate field with
its own rules (CLAUDE.md §1.6): a backward-incompatible
schema change bumps it and ships a migrator, independent of the package
version.