How releases are cut and how the automated publish pipeline works.
Genblaze ships ~15 PyPI packages and one npm package from this monorepo.
Each package's pyproject.toml carries its own version number, and they
do not all move in lockstep — e.g. the 0.3.0 wave bumped genblaze-core
and every connector to 0.3.0, but left genblaze-s3 and genblaze-cli
unchanged because their code hadn't shifted. In the same wave the umbrella
genblaze package moved from 0.3.2 → 0.4.0 (it had its own version
history that ran ahead of core), so the wave is called "0.3.0" but the
umbrella package version is 0.4.0. That's expected, not a bug.
We call each release a wave. The canonical name of the wave is the
most recent versioned heading in CHANGELOG.md. The GitHub Release tag
follows the wave name — not any individual package's version — because:
- No single package is bumped on every wave (a connectors-only wave
wouldn't touch
core; a core-only wave wouldn't touch every connector). - The CHANGELOG is the only file that always exists for every wave and always reflects the maintainer's stated release name.
- Per-package PyPI versions are still whatever each
pyproject.tomlsays — the workflow publishes them as-is.
Convention:
| Surface | Source of truth |
|---|---|
| GitHub Release tag | v + latest ## [X.Y.Z] - … heading in CHANGELOG.md |
| Per-package PyPI versions | Each package's own pyproject.toml |
@genblaze/spec version (npm) |
libs/spec/package.json |
| What changed | CHANGELOG.md (Keep a Changelog format) |
The release workflow validates tag == "v" + latest CHANGELOG wave name
before doing anything else. A mismatch fails the run.
Before cutting a release, verify on main:
- Versions are bumped. Every package whose code changed since the
last release has its
pyproject.tomlversionupdated. The umbrellalibs/meta/pyproject.tomlis bumped to reflect this release. - CHANGELOG is cut. The
[Unreleased]section is empty; a new## [X.Y.Z] - YYYY-MM-DDsection lists every package version change under "Released package versions". - TS types are current.
make ts-typesproduces no diff. (CI'sts-types-checkjob already enforces this on every push, but the release workflow re-runs it as a defense in depth.) - CI is green on the commit you're about to release.
Step 3 plus the local equivalents of every gate the publish pipeline will run are bundled into a single target:
make pre-releaseThis runs lint, typecheck, ts-types-check, pypi-metadata-check,
pypi-pin-parity, test, and release-smoke in quick-fail order. lint
now also runs the deptry dependency-hygiene gate (make deptry), which
fails if any package imports a dependency it doesn't declare — the
clean-install crash class behind #37/#106. A clean run on main is a strong
signal that validate-version, changelog-gate, and release-smoke in the
workflow will all be green once you tag.
Defined in .github/workflows/release.yml.
Triggered two ways:
- Manually create an annotated Git tag matching the latest CHANGELOG
wave name:
# If CHANGELOG's most recent versioned heading is `## [0.3.0] - …`: git tag -a v0.3.0 -m "Release 0.3.0" git push origin v0.3.0
- Create a GitHub Release on that tag. Paste the CHANGELOG slice for this version as the body.
- Publishing the Release fires the
Releaseworkflow.
The workflow then runs:
validate-version ─┐ ┌──> publish-cli
├──> publish-core ─────────────┤
changelog-gate ───┤ └──> publish-connectors (matrix×13) ──> publish-meta ─┐
│ ├──> install-verify
release-smoke ────┤ │
│ │
pin-parity ───────┘ │
│
publish-npm ───────────────────────────────────────────────────────────────────────────────────────────┘
Notes on the graph:
-
validate-version,changelog-gate,release-smoke, andpin-parityare the four pre-publish gates. All must pass before any package is built. -
publish-cliruns in parallel with the connector matrix but is NOT on the path toinstall-verify. Apublish-clifailure marks the overall run red but doesn't cancel verify — verify is a smoke test for the umbrella import, not a full release-wide check. -
publish-npmruns in parallel with the entire PyPI graph (no PyPI dep), butinstall-verifywaits for bothpublish-metaandpublish-npmbefore declaring the wave released. -
validate-version— reads the canonical wave name from the most recent versioned heading inCHANGELOG.md, enforcestag == "v$wave"onreleasetrigger, and emits the wave name + per-package versions +dry_runflag to every downstream job. -
changelog-gate— fails if[Unreleased]still has entries. -
release-smoke— runsmake release-smoke: builds every wheel, installsgenblaze[all]from a local wheelhouse with--no-index, imports every connector. Catches version-pin mismatches before PyPI sees them. -
pin-parity— for every package, compares source[project.dependencies]against the wheel already on PyPI at the same version. Fails the release if they diverge. Closes theskip-existingtrap that shipped twice (s3 in 0.3.0, langsmith + cli in 0.3.2): without this gate, a package whose pin was widened in source but whose version was never bumped will be silently skipped on every release, leaving the broken wheel resolvable. Run locally viamake pypi-pin-parity(also bundled intomake pre-release). -
publish-core— must publish first; everything else pins it. -
publish-cli+publish-connectors— fan out in parallel. The connectors matrix usesfail-fast: falseso one transient PyPI hiccup doesn't strand the others. -
publish-meta— last, because the umbrella's[project.optional-dependencies]block references connector packages by version constraint, and pip needs those targets to already be on PyPI to resolvepip install "genblaze[all]". -
publish-npm— independent of the PyPI graph. Publishes@genblaze/specwith sigstore provenance. -
install-verify— installsgenblaze[all]==$versionfrom public PyPI in a fresh venv and imports the core packages. The[all]form exercises every connector's pin against the live registry, which is what catches drift like the 0.3.2 langsmith/cli wheels (a baregenblaze==$versionresolve only pulls the umbrella defaults and misses connector-specific pin breakage). Skipped on dry-runs (TestPyPI indexing lag makes this flaky).
Use this to exercise the full pipeline without touching production registries. From the GitHub Actions tab → Release workflow → "Run workflow":
dry_run— defaults totrue. PyPI publishes route to TestPyPI; npm publish runs with--dry-run. No production registry changes.ref— optional. Defaults to the branch you dispatched from.
Dry runs are the recommended way to:
- Test the workflow after editing
release.yml. - Verify Trusted Publishing is configured correctly on a brand-new package before its first real publish.
- Sanity-check a release candidate without using up a version number on prod PyPI (you can't re-publish the same version; a botched 0.4.0 burns the slot forever).
Before this workflow can run successfully, three external setups must exist:
For each of the ~15 Python packages, on its PyPI project page, add a Trusted Publisher entry with:
- Repository owner:
backblaze-labs - Repository name:
genblaze - Workflow filename:
release.yml - Environment name:
pypi
No PYPI_API_TOKEN is needed once Trusted Publishing is configured —
the workflow uses OIDC and the action obtains a short-lived token at
publish time.
Same setup as above, but on test.pypi.org instead of pypi.org, with
environment name testpypi. This is what the dry-run path uses.
If you skip this, dry-runs will fail at the publish step (after building and gating, which still gives you ~90% of the value).
Add an NPM_TOKEN repository secret containing a publish-scoped
token for @genblaze/spec.
Token type: Automation token with publish scope on the @genblaze
org. Generate it from https://www.npmjs.com/settings/genblaze/tokens
→ "Generate New Token" → "Automation".
If/when npm Trusted Publishing supports scoped public packages on your
org by default, replace the NODE_AUTH_TOKEN env var in publish-npm
with the OIDC-based flow (same pattern as PyPI Trusted Publishing) and
delete the NPM_TOKEN secret.
Create four environments under repo Settings → Environments:
pypi— production PyPI. No required reviewers.testpypi— TestPyPI for dry-runs. No required reviewers.npm— production npm. No required reviewers.npm-dryrun— npm dry-run. No required reviewers.
Required reviewers can be added later if you want a human gate on production releases (recommended once the project graduates from alpha).
- Re-running a partial release is safe. Both PyPI (
skip-existing: trueon the publish action) and the npm job (pre-check vianpm view) treat "this version is already published" as a no-op success. So if half the connectors land and then a network blip kills the run, re-trigger the same Release event — published packages are skipped, unpublished ones go out. - Bumping a version because you need a new artifact. PyPI does
not allow republishing the same version number even with
--force; if a wheel was built with bad metadata and you need to fix it, you must bump the patch version (0.3.0→0.3.1) and re-tag. - Connector failures are recoverable. Because the connector matrix
uses
fail-fast: false, a single failed connector leaves the others published. To recover, fix the issue, bump that connector's patch version, and trigger a new release — already-published packages are silently skipped. - The umbrella is the long pole. If
publish-metafails, users cannotpip install genblaze— but they can still install individual packages. Treat a meta failure as a P0. - install-verify is best-effort. PyPI's CDN sometimes lags; the job
retries for 2 minutes before failing. A red
install-verifyafter a green publish graph usually means the package is fine and the index is just behind — verify manually withpip install genblaze==X.Y.Zin a fresh venv before assuming a regression.
install-verify runs inside the workflow, but it lives in the same
GitHub Actions runner that just published — same network, same caches.
After a release lands, do an independent check from your local machine
against public PyPI:
make post-release VERSION=0.4.0VERSION is the umbrella version from libs/meta/pyproject.toml,
not the wave name (e.g. wave 0.3.0 shipped umbrella 0.4.0). The target
creates a throwaway venv in /tmp, installs genblaze[all]==$VERSION
from public PyPI, imports genblaze_core and genblaze_s3, and prints
the installed versions of each. On failure the venv is left in place
so you can re-run the failing command interactively.
This is the same check that caught the 0.3.0 genblaze-s3 dependency-
pin drift after install-verify lagged — it's a backstop, not
redundant.
If the workflow is broken and a release must ship, the legacy manual path still works:
make release-smoke # build + install + import all
cd libs/core && python -m build && twine upload dist/*
# repeat for cli, every connector, then libs/meta last
cd libs/spec && npm publish --access public --provenanceThis bypasses the changelog gate, version-tag validation, and install-verify. Use only when the automated path is unavailable.