Skip to main content

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/:

PackageDistribution namePythonNotes
packages/candidate_selectioncandidate-selection3.14OEM-agnostic candidate scoring
packages/ai_coreai_core3.14Shared platform library; also consumed by OEM projects as an editable path dependency for in-repo builds
packages/ai_ppmai-ppm3.14Standalone volume-optimization library; no in-repo consumer yet
packages/ai_httpai_http3.14HTTP transport helpers (sessions, retry, pagination, throttling); depends on ai_core, consumed by every OEM project as an editable path dependency
packages/ai_dagsterai_dagster3.14Dagster 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_nessieai_nessie3.14nessie 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):

CommitExampleBump
fix:fix(candidate-selection): clamp negative CDFpatch
feat:feat(candidate-selection): add dealer fallbackminor
feat!: / BREAKING CHANGE:breaking API changeminor (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

FileRole
release-please-config.jsonDeclares each publishable package, its release-type (python), tag component, and bump policy
.release-please-manifest.jsonSource of truth for the last released version per package; release-please reads and writes this
.github/workflows/release-please.ymlRuns release-please on push to main, then the conditional per-package publish jobs
.github/workflows/publish-package.ymlReusable workflow_call that runs pants publish <target> (build + Nexus upload); called by release-please
packages/<pkg>/BUILDpython_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.mdGenerated 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 the main tip when automation was introduced. ai_core 0.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, run pants publish packages/ai_core:dist locally once with the Nexus TWINE_* credentials set.
  • ai_ppm-v0.1.0 — anchored at the commit that introduced the package. Same situation as ai_core's bootstrap: 0.1.0 was never published to Nexus, so it needs the same one-time manual pants publish packages/ai_ppm:dist seed before the next automated release PR takes over.
  • ai_http-v0.0.0 — anchored at the main tip before the commit that introduced the package, with [project].version seeded to 0.0.0. This deliberately avoids the ai_core / ai_ppm bootstrap hole: because the introducing feat(ai_http): … commit is merged after the anchor, the first release-please run cuts 0.0.1 and publishes it automatically. There is no manual pants publish seed, and no version that exists in git but never on Nexus.
  • ai_dagster-v0.0.0 — anchored the same way as ai_http, below the commit that split the Dagster layer out of ai_core.
  • ai_nessie-v0.0.0 — anchored the same way, below the commit that renamed nessie_viz and moved the nessie CLI into it out of ai_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

  1. Give it a pyproject.toml with [project].version = "0.0.0" under packages/<pkg>/.

  2. Add a block under packages in release-please-config.json (release-type: python, a unique component). Add an extra-files entry for every uv.lock that pins the package — its own, plus each consuming project's — or those lockfiles go stale the moment the version bumps.

  3. Add "packages/<pkg>": "0.0.0" to .release-please-manifest.json, and push the tag <pkg>-v0.0.0 against the main tip before the introducing commit. Anchoring below the introducing commit is what makes the first automated release PR publishable (see Tags and the version anchor).

  4. Add the package to the Pants graph (BUILD files, plus a resolve in pants.toml if it needs its own) including a python_distribution(name="dist", …, generate_setup=False, repositories=[<nexus url>]) target.

  5. Add the <pkg>_released output and a publish job to release-please.yml, conditioned on that package's --release_created output, calling publish-package.yml with target: packages/<pkg>:dist. Missing this is what left ai_ppm unpublishable on its first release.

    The action also offers a <path>--tag_name output. We do not surface it: its documented purpose is gh 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

SecretUsed by
NEXUS_USERNAME, NEXUS_PASSWORDtwine upload to the private index (already configured)
RELEASE_PLEASE_TOKENGitHub 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.