Releasing¶
Cutting a release of beam-agents is one deliberate human act — pushing an
annotated vX.Y.Z tag — and nothing else. Everything between that tag and
PyPI is automated by .github/workflows/release.yml,
verified, and fails closed.
Versioning policy¶
From 1.0.0 the project is versioned MAJOR.MINOR.PATCH, semver over a
frozen public surface:
| release | may contain |
|---|---|
MINOR (X.Y.0) |
new features and new deprecations; the removal of a name whose deprecation window has elapsed — every break carries a breaking changelog entry naming the migration |
PATCH (X.Y.Z) |
defect fixes and documentation only; the compatibility surface does not change (machine-checked — see the fragment table below) |
The frozen surface is public-surface.toml:
one entry per public module, holding the public top-level names it declares
and its __all__. It is generated, never hand-edited, and
tests/test_public_surface.py re-derives it from the sources on every
make test-unit run and compares by exact equality in both directions — an
unreviewed addition and an unreviewed removal both fail. Regenerating the
snapshot is the API review: the diff must land in the same PR as the code
that moved a name.
The deprecation window. A frozen public name may not be removed or renamed without a window of at least one minor release:
- During the window the old name MUST keep working and MUST emit a
DeprecationWarningnaming the replacement (or stating there is none) and the first release that may remove it. - The deprecated name is served from a module-level
__getattr__callingbeam_agents._deprecation.deprecated_attribute— the PEP 562 patternbeam_agents/__init__.pyalready uses for lazy adapter classes — which keeps it out of the module namespace. Both edges of the window are thereforepublic-surface.tomlsnapshot diffs: opening it removes a declared name (reviewers check that the shim and the warning exist), and closing it one minor later removes the shim. Neither can happen silently. - The removal release carries a
breakingfragment naming the migration.
The mechanics, with the shim code to copy, are in
CONTRIBUTING.md.
The 0.x line (history)¶
Before 1.0 the project was versioned 0.MINOR.PATCH under a deliberately
weaker rule: a 0.MINOR.0 release could break the compatibility surface
without any deprecation window, as long as the break carried a breaking
changelog entry naming the migration, and a 0.x.PATCH release carried fixes
and documentation only. That regime ended at 1.0.0; it is recorded here
because the 0.x sections of CHANGELOG.md were written against it.
The compatibility surface¶
These, and only these, are under the policy:
- The public API — exactly the names
public-surface.tomlrecords, for every public module. At the root that is the sixteen namesbeam_agents/__init__.pyre-exports:AdkAgent,AgentConfig,Deny,Drop,Escalate,FallbackContext,HitlPolicy,LangGraphAgent,PydanticAIAgent,RunAgent,RunAgentOutputs,ShardKeys,StreamAgent,shard_key,tool,unshard_key. Everything underscore-prefixed, and everything outside the snapshot, is private. - The wire and state protobuf schemas. Changes are additive-only within a
MINOR line. A
state_schema_versionbump is by definition MINOR-requiring: it ships with its lazy migration and abreakingentry in the same release, because an in-flight pipeline's--updatecompatibility depends on it. - The
beam-agents-effectorCLI — its flags and its exit behaviour. - The published extras and the console-script names — the nine extras
(
effector,langgraph,pydantic-ai,adk,otlp,vllm,memory-stores,console,console-ingest) and the three scripts (beam-agents-effector,beam-agents-replay,beam-agents-console). Adding an extra isadded; renaming or removing one isbreaking. - The supported Python range (
requires-python) and the supported-runner list (DirectRunner, Dataflow, Flink supported; Spark best-effort). Narrowing either isbreaking.
Explicitly out of contract at any version: underscore-prefixed modules,
tests/, scripts/, Make targets, CI workflow shape, and anything reachable
only by importing a private submodule.
Fragment type → version component¶
scripts/check_release.py machine-checks this table at release time: a PATCH
tag (vX.Y.Z with Z > 0) whose pending fragments include any MINOR-requiring
type fails verification before anything is built.
| fragment type | rendered heading | version component |
|---|---|---|
breaking |
Breaking changes | requires MINOR |
added |
Added | requires MINOR |
changed |
Changed | requires MINOR |
fixed |
Fixed | PATCH-compatible |
docs |
Documentation | PATCH-compatible |
internal |
not rendered | PATCH-compatible |
The registry is closed. An unregistered type (.feature.md) fails the
changelog-fragment-required pre-commit hook and make changelog alike, so it
can never be silently dropped from the notes. See
changelog.d/README.md
for how to write one.
Where the version lives¶
The version is a single static string: [project].version in
pyproject.toml. It is never derived from git state — uv.lock records
the project's own version and every CI job installs with uv sync --locked, so
a tag-derived version would make the lockfile disagree with the build on every
commit.
Three things must agree, and scripts/check_release.py proves it before
anything is built for publishing:
A bump therefore always comes with uv lock. This is the one routine reason
uv.lock changes without a dependency change.
Release checklist¶
Before opening the release PR:
- [ ] The required checks (
ci,integration,quality,docs) are green on the commit you intend to release from, and it is onmain. - [ ] The latest
nightlyrun is green — thedataflowandsmoketiers have no required-check enforcement to inherit, so this is a human check. - [ ] No open latency-budget regression. The runtime overhead budget (p50
< 15 ms, p99 < 60 ms per activation, excluding LLM and tool time) is a
release blocker, machine-checked twice over:
make bench-gate(scripts/bench_gate.py) enforces the absolute budget plus the per-benchmark ratchet againstbenchmark-baseline.toml— seeded 2026-08-03 from scheduled nightly run 30806138398 (ubuntu-latest) — and the release workflow itself refuses to publish without a recent greenbenchrun (see "What the tag triggers"). What remains a human check is only that the latest nightly bench is the one you expect. - [ ]
changelog.d/reads like release notes for a user, not a work log.
The release PR:
- Bump
[project].versioninpyproject.toml. - Sweep the version-coupled references — this exact gap has been rediscovered
at three consecutive milestones, so it is a checklist step, not tribal
knowledge.
grep -rnfor the outgoing version and update: docs/yaml.md— thebeam-agents==X.Y.Zprovider pins (test-guarded);src/beam_agents/yaml/providers.yamlandsrc/beam_agents/yaml/__init__.py— the same pin in the provider listing's comment and the module docstring;docs/replay.md— the version the transcript showsbeam-agents-replayprinting (the CLI prints the installedimportlib.metadataversion);website/lib/site.ts—PACKAGE_VERSION;uv.lock— handled by step 3, listed here so the sweep is complete.uv lock— then confirmuv sync --lockedstill succeeds.make changelog VERSION=X.Y.Z— assembles the pending fragments into a datedCHANGELOG.mdsection and deletes the fragments it consumed. Preview first withmake changelog-draft, which writes nothing.make build && uv run python scripts/check_wheel.py distlocally if you want to eyeball the artifact. Local builds are for inspection only; nothing outsiderelease.ymluploads.- Open, review, squash-merge.
Then tag the squash-merge commit:
What the tag triggers¶
release.yml runs two jobs, and the second is unreachable unless the first
fully passes:
build-and-verify(no publish permissions, no OIDC token):uv sync --locked→make build→scripts/check_release.py(tag == version == lock, tagged commit is an ancestor ofmain, fragment policy) →scripts/check_wheel.py(the artifact actually containspy.typed, the generated_protosbindings, the console script, and the right metadata, and contains no test/docker/CI content) → the offline gate roster (make lint type test-unit test-semantics-offlineplus the semantics-tier partition check) re-run on the exact tagged ref → uploaddist/.publish(environment: pypi,id-token: write): before publishing anything, locate the most recent nightly run onmainwhosebenchjob concluded green — read via the jobs API, not the run conclusion, since the siblingdataflow/smokejobs can be red independently — and download itsbenchmark-reportartifact. No green bench run in the last 30 nightly runs fails the release here, before anything reaches PyPI: the latency budget is a release blocker, and a green bench job is exactly a runscripts/bench_gate.pypassed against the seededbenchmark-baseline.toml. Then download the build artifact, publish via PyPI trusted publishing, and create the GitHub Release for the tag with that version'sCHANGELOG.mdsection as its body andbench-report.mdplusbench-results.zipattached.
The docker-backed gates (the effectively-once e2e gate, the adapter conformance
matrix's Flink leg) are deliberately not re-run here: they are required checks
on main, and check_release.py has just proved the tagged commit is an
ancestor of main. Re-running the compose-backed suites on an already-gated
commit costs latency and flake surface and produces no new information.
Trusted publishing¶
There is no PyPI API token anywhere in this repository's secrets.
Publishing authenticates via OIDC, the same no-long-lived-credential stance
nightly.yml takes with Workload Identity Federation for GCP.
The bindings are configured once on the PyPI side. Record them here verbatim — renaming the workflow file or the environment breaks publishing with an OIDC error, and this table is what makes the fix mechanical:
| index | owner | repository | workflow | environment |
|---|---|---|---|---|
| PyPI | ardada2468 |
beam-agents |
release.yml |
pypi |
| TestPyPI | ardada2468 |
beam-agents |
release.yml |
testpypi |
The pypi environment can additionally be configured to require a reviewer
approval between verification and publish; the two-job shape supports that with
no workflow change.
Rehearsing on TestPyPI¶
PyPI filenames are immutable: a botched first upload of a version can be
yanked, never replaced. Before the first real tag, rehearse via
workflow_dispatch on release.yml (leave rehearse checked), which runs the
identical build-and-verify job and publishes to TestPyPI instead. Then, from a
clean environment, verify:
pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ beam-agents
python -c "import beam_agents; print(beam_agents.RunAgent)"
beam-agents-effector --help
…and repeat for each of the nine extras (beam-agents[effector],
[langgraph], [pydantic-ai], [adk], [otlp], [vllm],
[memory-stores], [console], [console-ingest]), plus
beam-agents-replay --help and beam-agents-console --help for the other
two console scripts.
If a release goes wrong¶
Published artifacts are yanked, never deleted and re-uploaded — the version
number is spent. Land the fix on main and release X.Y.Z+1 per the policy.
If publishing fails after tagging but before anything reached PyPI, delete or
supersede the tag and re-push once the fix is merged.