feat(release): cut alpha releases from dev, beta from main - #554
Conversation
Alpha and beta had no distinct meaning in the release flow: RELEASE.md described them only as PEP 440 spellings, and both `prepare` and `publish` hard-required `main`, so there was no way to cut a staging release from `dev` at all. The one alpha in the repo's history was in fact tagged on `main`. Adds `release.py alpha <bump>`, which bumps, commits, tags, pushes, watches the Release workflow and verifies in one step on `dev`. Its wheels reach PyPI as a pre-release so the hosted MCP server's deployment wrapper can pin an exact version and exercise a release in staging before it ships. `release-pr beta` then promotes the alpha to the first beta of the same X.Y.Z. An alpha does not stamp CHANGELOG.md. `## [Unreleased]` keeps accumulating across alpha.1..alpha.N and is what the Release workflow uses as an alpha's release body, so the beta promotion still stamps the whole set into one section instead of finding the notes already spent on an alpha heading. The branch a tag may be cut from is derived from the version's own pre-release track rather than the operator's checkout, and checked three times: before the bump, again immediately before the tag (the bump commit lands in between), and in the Release workflow, which asserts the tagged commit is an ancestor of origin/dev for an alpha or origin/main otherwise before any wheel is built. install.sh now resolves the newest release whose tag is not an alpha. Without this, publishing an alpha would repoint the public `curl | sh` install at an untested staging build, since it resolved whatever release was newest and every release is flagged prerelease=false. Alphas are now flagged as GitHub pre-releases too, keeping the "Latest" badge on the newest beta.
adriannoes
left a comment
There was a problem hiding this comment.
This lands a clear alpha-vs-beta release model: staging cuts off dev without stamping CHANGELOG, promotion via release-pr beta, server-side ancestor checks before wheels, and install.sh skipping alpha tags so public curl|sh stays on the beta line. Local tests for the helpers look solid, and CI is green on dev after the #553 back-merge.
Required before merge
release_pr still lacks a release_branch_for gate. On an alpha line, release-pr prerelease (or an explicit alpha version=) stamps CHANGELOG and opens a main-bound PR; publish only refuses afterward. That breaks the "alphas do not stamp / only alpha cuts alphas" contract. Mirror the alpha/publish checks so release_pr (and preferably prepare) refuse alpha-shaped targets before mutating, with a regression test.
Also noted
verify_installer_skipsonly asserts the just-cut tag is not selected. The tests lock that narrower inverse-of-resolves contract; asserting the resolved tag is non-alpha (or tightening the docstring) would match the stronger claim in the prose.- Alpha classification is duplicated: Python uses
prerelease_track(PEP 440), whileinstall.shand the workflow shell regex are case-sensitive and require a trailing counter. Happy-path lowercase tags frombump_versionagree; mixed-case or counter-lessversion=spellings can diverge. RELEASE.mdstill exemplifiesversion=0.5.0-alpha.1under thepreparebump list, and the alpha/beta table overclaims that a default--preinstall stays on beta once a higher-core alpha exists on PyPI.
What worked well
Deriving the required branch from the version track, leaving Unreleased intact across alphas, flagging GitHub pre-releases only for alphas, and verifying that install.sh does not resolve the just-cut alpha are the right controls for the hosted staging pin use case.
| run(["git", "fetch", "--quiet", "origin", MAIN_BRANCH]) | ||
| current, target = compute_target_version(bump_arg) | ||
|
|
||
| if release_branch_for(target) != DEV_BRANCH: |
There was a problem hiding this comment.
alpha refuses a non-alpha target here via release_branch_for before it bumps. release_pr never does the mirror check.
On an alpha line, release-pr prerelease (or version=...-alpha.N) still calls _apply_prepare with the default stamp=True, burns ## [Unreleased] into an alpha heading, and opens that commit toward main. publish refuses later, so PyPI stays safe, but the CHANGELOG contract this PR introduces is already broken, and a merged release PR would land that pollution on main.
I would refuse in release_pr when release_branch_for(target) != MAIN_BRANCH (the inverse of this gate), and add a unit test for prerelease and version=...-alpha.N on an alpha current. Optionally the same gate on prepare.
Done when:
release-prraises before checkout/bump for alpha-shaped targets- a regression test fails if that gate is removed
`alpha` and `publish` gated on the version's pre-release track, but `prepare` and `release_pr` did not. On an alpha line, `release-pr prerelease` computed the next alpha and proceeded with the default `stamp=True`: it burned the accumulating `## [Unreleased]` section into an alpha heading and opened that commit toward `main`. `publish` refused the tag afterward, so nothing reached PyPI, but the CHANGELOG contract this flow depends on was already broken, and merging the release PR would have carried it onto `main`. Both main-bound flows now refuse an alpha-shaped target before touching a branch or a file, via the same `release_branch_for` derivation the other two use. The `beta` promotion is unaffected, which a test pins. Also from review: - `verify_installer_skips` asserted only that the just-cut alpha was not selected. A filter that skipped just the newest alpha would leave the installer on an older staging cut -- the same leak -- so it now asserts the resolved tag is not an alpha at all. - The alpha-tag regexes in `install.sh` and the workflow's provenance guard were case-sensitive while the release-notes step's own match and `prerelease_track` are not, so the same tag could classify two ways inside one workflow. Both greps now match case-insensitively. - RELEASE.md claimed a `--pre` install stays on the beta line. It does not: `--pre` takes the highest pre-release, so a published `0.5.0-alpha.1` outranks `0.4.0-beta.2`. Only `install.sh` filters alphas. The `prepare` bump example no longer uses an alpha, which that command now refuses.
|
Thanks — the required one was a real bug, reproduced and fixed. Pushed in 940ab2e. Required:
|
adriannoes
left a comment
There was a problem hiding this comment.
The earlier blocking item is addressed: prepare / release_pr now refuse alpha-shaped targets via _refuse_alpha_target, with regression coverage, and the follow-ups from the first review (installer skip asserting non-alpha, case-insensitive alpha greps, RELEASE.md --pre / prepare example) look good. CI is green on this head.
One attention point for you to decide: the same fix commit also bumps the workspace from 0.4.0-beta.2 to 0.5.0a1 (package __version__s, root/plugin metadata, uv.lock). That is outside the release-tooling change described in the commit message, and merging it would land an alpha version on dev as part of this PR. If that was intentional as the first staging cut, fine; if it was incidental from exercising the bump locally, consider reverting the version/lock files to 0.4.0-beta.2 before merge and cutting the alpha separately with release.py alpha.
Approving either way; your call on the version bump.
… commands Reverts the workspace version, both Claude manifests, every package's `__init__.py` and sibling pins, and `uv.lock` to `0.4.0-beta.2`. The bump to `0.5.0a1` in 940ab2e was an accident, not the first staging cut: it came from exercising the new `prepare` gate locally, where `test_prepare_refuses_an_alpha_target` did not stub `run`, so with the gate stubbed out `prepare` fell through to a real `bump_version.py version=0.5.0-alpha.1`. `uv.lock` also carried an unrelated lockfile-format migration (`revision = 1` -> `2`, `upload-time` metadata added throughout) because the local uv is newer than the one that wrote the committed lockfile. The alpha gets cut with `release.py alpha` on its own. The root cause was the test, so `tests/test_release.py` gains an autouse fixture that turns `run` and `capture` into hard failures. These tests exercise release decisions, not the commands they authorize; a guard that stops working now fails with "test reached a real subprocess" instead of silently rewriting every version-bearing file in the checkout. Tests needing a command stub one explicitly. Verified by re-running the same mutation: it fails loudly and the working tree is untouched.
|
Good catch, and it was incidental — not the first staging cut. Reverted in 5a1774b. It came from exercising the new gate: Worth flagging what else rode along, since it was not just the version: Since the test was the root cause rather than the symptom, One consequence for whoever cuts the alpha: |
adriannoes
left a comment
There was a problem hiding this comment.
Thanks for the follow-up. 5a1774b drops the stray 0.5.0a1 bump (back to 0.4.0-beta.2) and hardens the prepare refusal test so it cannot shell out for real. The release-flow fixes from 940ab2e still look good, and CI is green on this head.
Approved.
Motivation
Alpha and beta had no distinct meaning in the release flow.
RELEASE.mddescribed them only as PEP 440 spellings, and bothprepareandpublishhard-requiredmain, so there was no way to cut a staging release fromdevat all — the one alpha in the repo's history was in fact tagged onmain. That left no supported way to exercise a release in staging before it ships, which is what the hosted MCP server's deployment wrapper needs, since it pins an exact PyPI version.Outcome
release.py alpha <bump>cuts a staging alpha offdevin one step — bump, commit, tag, push, watch the Release workflow, verify — publishing to PyPI as a pre-release so the hosted wrapper can pin it.release-pr betathen promotes that alpha to the first beta of the sameX.Y.Z(0.5.0-alpha.3->0.5.0-beta.1) through the existingdev->mainrelease PR.An alpha deliberately does not stamp
CHANGELOG.md.## [Unreleased]keeps accumulating acrossalpha.1..alpha.Nand is what the workflow uses as an alpha's release body, so the beta promotion still stamps the whole set into one section instead of finding the notes already spent on an alpha heading.Which branch a tag may be cut from is derived from the version's own pre-release track rather than the operator's checkout, and enforced three times: before the bump, again immediately before the tag (the bump commit lands in between), and in the Release workflow itself, which asserts the tagged commit is an ancestor of
origin/devfor an alpha ororigin/mainotherwise — before any wheel is built, so a tag pushed by hand from the wrong branch fails instead of publishing.install.shnow resolves the newest release whose tag is not an alpha. This closes a live regression the alpha flow would otherwise have introduced: the installer resolved whatever release was newest and every release is flaggedprerelease=false, so the first alpha would have repointed the publiccurl … | shinstall at an untested staging build. Alphas are now flagged as GitHub pre-releases too, keeping the "Latest" badge on the newest beta.install.sh --version vX.Y.Z-alpha.Nstill installs one on purpose.Notes
Because the beta promotion stamps the CHANGELOG on a branch that merges to
main,devdrifts again after every beta — the same conflict #553 resolves will recur each cycle. Having the release PR also back-merge itself is a separate change, not folded in here.