Releases
Publishable Python libraries live under packages/. Each is uploaded as a
wheel + sdist to the private Nexus index
https://nexus.sulzer-us.com/repository/pypi-private/:
| Package | Distribution name | Python | Notes |
|---|---|---|---|
packages/candidate_selection | candidate-selection | 3.14 | OEM-agnostic candidate scoring |
packages/ai_core | ai_core | 3.14 | Shared platform library; also consumed by OEM projects as an editable path dependency for in-repo builds |
packages/ai_ppm | ai-ppm | 3.14 | Standalone volume-optimization library; no in-repo consumer yet |
packages/ai_http | ai_http | 3.14 | HTTP transport helpers (sessions, retry, pagination, throttling); depends on ai_core, consumed by every OEM project as an editable path dependency |
packages/ai_dagster | ai_dagster | 3.14 | Dagster orchestration layer (components, Iceberg IO manager, sensors, jobs); depends on ai_core and ai_http, consumed by every OEM project as an editable path dependency |
packages/ai_nessie | ai_nessie | 3.14 | nessie CLI (login, viz) and the DuckDB-WASM browser visualizer; depends on ai_core, consumed by services/ai_nessie_viz |
A package under packages/ with its own pyproject.toml and [project].version
is either a release candidate or explicitly not one: the python_distribution
target in its BUILD, a release-please-config.json block, a manifest entry, and
a conditional publish job either all exist or all don't. scaffold-package --no-publish
generates a library with none of the four, for one meant to stay in-repo.
scripts/tests/test_publishable_packages.py fails on anything in between — a
package with some of the four and not others. OEM projects under projects/ ship
as Docker images, not PyPI artifacts (see Dagster Execution).
Versions are independent per package (separate-pull-requests: true) — one
release PR each, so ai_core and candidate-selection move on their own
numbers. They are not locked together.
Versioning is automated from Conventional Commits
We do not hand-edit version numbers. release-please
runs on every push to main, reads the Conventional Commit
history scoped to each package's path, and maintains a standing "release
PR" that bumps the version and changelog. Merging that PR tags the release and
triggers the Nexus upload.
Bump rules (package is pre-1.0, so breaking changes bump the minor):
| Commit | Example | Bump |
|---|---|---|
fix: | fix(candidate-selection): clamp negative CDF | patch |
feat: | feat(candidate-selection): add dealer fallback | minor |
feat!: / BREAKING CHANGE: | breaking API change | minor (pre-1.0) |
chore: / docs: / refactor: | — | no release |
Path scoping is the reason release-please was chosen over commitizen: in this
monorepo only commits that touch files under a package's directory count
toward its version. A feat(mercedes): ... commit never bumps
candidate-selection, and a fix(ai_core): ... commit bumps only ai_core.
Flow
Each package has its own publish job (publish-candidate-selection,
publish-ai-core, publish-ai-ppm, publish-ai-http, publish-ai-dagster) in the same workflow
run as release-please, conditioned on that
package's --release_created output. Each job calls the reusable
publish-package.yml workflow, which runs pants publish <pkg>:dist — Pants
builds the sdist + wheel via the package's [build-system] (hatchling, PEP 517,
so the version comes from pyproject.toml) and twine-uploads to Nexus. Keeping
publish in the same run sidesteps the GitHub gotcha that workflows triggered by
the default GITHUB_TOKEN do not themselves trigger further workflows.
Files that drive it
| File | Role |
|---|---|
release-please-config.json | Declares each publishable package, its release-type (python), tag component, and bump policy |
.release-please-manifest.json | Source of truth for the last released version per package; release-please reads and writes this |
.github/workflows/release-please.yml | Runs release-please on push to main, then the conditional per-package publish jobs |
.github/workflows/publish-package.yml | Reusable workflow_call that runs pants publish <target> (build + Nexus upload); called by release-please |
packages/<pkg>/BUILD | python_distribution(name="dist", …) — the publishable target Pants builds and uploads |
packages/<pkg>/pyproject.toml | [project].version — written by the release PR, never edited by hand |
packages/<pkg>/CHANGELOG.md | Generated and appended by release-please |
release-please-config.json:
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"separate-pull-requests": true,
"bump-minor-pre-major": true,
"packages": {
"packages/candidate_selection": {
"release-type": "python",
"package-name": "candidate-selection",
"component": "candidate_selection",
"changelog-path": "CHANGELOG.md"
},
"packages/ai_core": {
"release-type": "python",
"package-name": "ai_core",
"component": "ai_core",
"changelog-path": "CHANGELOG.md"
}
}
}
Each package also carries an extra-files entry pinning $.project.version in
its pyproject.toml so the version string is rewritten deterministically.
.release-please-manifest.json:
{
"packages/candidate_selection": "0.2.6",
"packages/ai_core": "0.1.0"
}
Tags and the version anchor
Tags are namespaced per package: candidate_selection-v0.3.0, ai_core-v0.2.0.
release-please finds the last release by matching this tag (and the manifest
entry), then only considers commits after it. Each package's current version is
anchored by a GitHub release on a main commit, so the first release PR
proposes the correct next bump rather than rescanning all history:
candidate_selection-v0.2.6— anchored at the commit that cut 0.2.6 (already on Nexus from the previous manual workflow).ai_core-v0.1.0— anchored at themaintip when automation was introduced.ai_core0.1.0 was never published to Nexus, so the first automated upload is the next version bump (e.g. 0.1.1); 0.1.0 itself is treated as the baseline, not republished. To seed 0.1.0 into Nexus, runpants publish packages/ai_core:distlocally once with the NexusTWINE_*credentials set.ai_ppm-v0.1.0— anchored at the commit that introduced the package. Same situation asai_core's bootstrap: 0.1.0 was never published to Nexus, so it needs the same one-time manualpants publish packages/ai_ppm:distseed before the next automated release PR takes over.ai_http-v0.0.0— anchored at themaintip before the commit that introduced the package, with[project].versionseeded to0.0.0. This deliberately avoids theai_core/ai_ppmbootstrap hole: because the introducingfeat(ai_http): …commit is merged after the anchor, the first release-please run cuts 0.0.1 and publishes it automatically. There is no manualpants publishseed, and no version that exists in git but never on Nexus.ai_dagster-v0.0.0— anchored the same way asai_http, below the commit that split the Dagster layer out ofai_core.ai_nessie-v0.0.0— anchored the same way, below the commit that renamednessie_vizand moved thenessieCLI into it out ofai_core.
Anchor a new package below its introducing commit (v0.0.0 on the previous
main tip) rather than at it. Anchoring at the introducing commit leaves that
version unreleasable, which is why ai_core 0.1.0 and ai_ppm 0.1.0 are not on
Nexus.
Adding another publishable package
-
Give it a
pyproject.tomlwith[project].version = "0.0.0"underpackages/<pkg>/. -
Add a block under
packagesinrelease-please-config.json(release-type: python, a uniquecomponent). Add anextra-filesentry for everyuv.lockthat pins the package — its own, plus each consuming project's — or those lockfiles go stale the moment the version bumps. -
Add
"packages/<pkg>": "0.0.0"to.release-please-manifest.json, and push the tag<pkg>-v0.0.0against themaintip before the introducing commit. Anchoring below the introducing commit is what makes the first automated release PR publishable (see Tags and the version anchor). -
Add the package to the Pants graph (BUILD files, plus a resolve in
pants.tomlif it needs its own) including apython_distribution(name="dist", …, generate_setup=False, repositories=[<nexus url>])target. -
Add the
<pkg>_releasedoutput and apublishjob torelease-please.yml, conditioned on that package's--release_createdoutput, callingpublish-package.ymlwithtarget: packages/<pkg>:dist. Missing this is what leftai_ppmunpublishable on its first release.The action also offers a
<path>--tag_nameoutput. We do not surface it: its documented purpose isgh release upload <tag> <artifact>, and this repo attaches nothing to the GitHub release — wheels go to Nexus via twine. Add it back per-package if that changes.
Steps 2, 4 and 5 are cross-checked by
scripts/tests/test_publishable_packages.py in both directions, so a half-done
addition fails CI instead of silently tagging a release that never publishes. It
also checks that each publish job's if: condition names its own package's output.
The manifest entry in step 3 is checked too. The tag push in that same step is
the one action that happens outside the repo and stays manual.
The introducing commit must be a feat(...) or fix(...) — a refactor(...)
bumps nothing, so the package would sit at 0.0.0 with every check successful and no
release PR ever cut.
separate-pull-requests: true keeps one release PR per package, so versions
move independently.
Manual / emergency publish
There is no manual publish workflow — releasing goes through the automated
release-please path. For an out-of-band republish or to seed a version, run
pants publish packages/<pkg>:dist locally with the Nexus TWINE_USERNAME /
TWINE_PASSWORD env set. It publishes the version currently in the package's
pyproject.toml.
Secrets
| Secret | Used by |
|---|---|
NEXUS_USERNAME, NEXUS_PASSWORD | twine upload to the private index (already configured) |
RELEASE_PLEASE_TOKEN | GitHub App / PAT so the release PR triggers normal CI; the default GITHUB_TOKEN opens the PR but does not trigger its test runs |
Nexus is publish-only: the repositories field on each python_distribution
target names it, and nothing in the repo installs from it.