Skip to content

feat(release): cut alpha releases from dev, beta from main - #554

Merged
mocha06 merged 3 commits into
devfrom
rc-dev/feat/release-alpha-from-dev
Jul 31, 2026
Merged

feat(release): cut alpha releases from dev, beta from main#554
mocha06 merged 3 commits into
devfrom
rc-dev/feat/release-alpha-from-dev

Conversation

@mocha06

@mocha06 mocha06 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Motivation

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. 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 off dev in 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 beta then promotes that alpha to the first beta of the same X.Y.Z (0.5.0-alpha.3 -> 0.5.0-beta.1) through the existing dev -> main release PR.

An alpha deliberately does not stamp CHANGELOG.md. ## [Unreleased] keeps accumulating across alpha.1..alpha.N and 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/dev for an alpha or origin/main otherwise — before any wheel is built, so a tag pushed by hand from the wrong branch fails instead of publishing.

install.sh now 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 flagged prerelease=false, so the first alpha would have repointed the public curl … | sh install 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.N still installs one on purpose.

Notes

Because the beta promotion stamps the CHANGELOG on a branch that merges to main, dev drifts 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.

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
adriannoes self-requested a review July 31, 2026 18:13
Base automatically changed from rc-dev/chore/back-merge-release to dev July 31, 2026 18:19
@mocha06 mocha06 closed this Jul 31, 2026
@mocha06 mocha06 reopened this Jul 31, 2026

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_skips only 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), while install.sh and the workflow shell regex are case-sensitive and require a trailing counter. Happy-path lowercase tags from bump_version agree; mixed-case or counter-less version= spellings can diverge.
  • RELEASE.md still exemplifies version=0.5.0-alpha.1 under the prepare bump list, and the alpha/beta table overclaims that a default --pre install 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.

Comment thread scripts/release.py
run(["git", "fetch", "--quiet", "origin", MAIN_BRANCH])
current, target = compute_target_version(bump_arg)

if release_branch_for(target) != DEV_BRANCH:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-pr raises 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.
@mocha06

mocha06 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — the required one was a real bug, reproduced and fixed. Pushed in 940ab2e.

Required: release_pr gate

Confirmed exactly as described. On an alpha line, release-pr prerelease computed 0.5.0-alpha.2, passed the ahead-of-main guard, and proceeded with the default stamp=True. Both main-bound flows now refuse an alpha-shaped target before touching a branch or a file, sharing one _refuse_alpha_target helper built on the same release_branch_for derivation the other two gates use. I gated prepare as well, not just release_pr — same one-line invariant, and an alpha stamped onto main locally is the same pollution.

Tests cover prerelease, an explicit version=…-alpha.N, the compact version=0.5.0a2 spelling, and a positive case pinning that the beta promotion still passes (so the gate can't be widened into rejecting any pre-release). I mutation-checked the "done when": stubbing the gate out fails 6 of them, restoring it passes all 40.

Also noted

verify_installer_skips — fair, it asserted "not this alpha" rather than "not an alpha". It now resolves the tag and asserts its track isn't alpha, so a filter that skipped only the newest alpha fails the check. Unparseable tags raise rather than silently pass.

Duplicated classification — one correction: the release-notes step already matches with re.I, so it isn't case-sensitive. That actually made it worse than described — inside a single workflow, the notes step and the new provenance guard could classify 0.5.0-ALPHA.1 two different ways. Both shell greps are now -i.

I checked where the remaining divergence lands before deciding how far to take it. prerelease_track uses PEP 440, so it accepts a counter-less 0.5.0a that the regexes reject. That path fails safe: the provenance guard classifies it non-alpha, demands main, the tag is on dev, and the ancestor check aborts the run before any wheel is built. Since the only way to reach it is hand-typing a non-canonical version= (every bump_version code path emits lowercase with a counter), I aligned the case sensitivity and left it there rather than adding a normalization layer. Happy to normalize in parse_explicit_version if you'd rather close it properly, though full PEP 440 normalization isn't an option — it would rewrite 0.5.0-beta.1 to 0.5.0b1 and break the tag convention.

RELEASE.md — both fixed. The --pre claim was outright wrong, so thanks for catching it: Version('0.5.0a1') > Version('0.4.0b2'), so a published alpha does win a --pre resolve. The table now claims only install.sh, with an explicit caveat that --pre / --prerelease allow can land on an alpha and you should pin exactly to get the beta from PyPI. The prepare example no longer uses an alpha, which that command now refuses anyway.

@mocha06
mocha06 requested a review from adriannoes July 31, 2026 18:45
adriannoes
adriannoes previously approved these changes Jul 31, 2026

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@mocha06

mocha06 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, and it was incidental — not the first staging cut. Reverted in 5a1774b.

It came from exercising the new gate: test_prepare_refuses_an_alpha_target did not stub run, so when I stubbed the gate out to check the "fails if the gate is removed" criterion, prepare fell through to a real bump_version.py version=0.5.0-alpha.1. That rewrote every version-bearing file, and I restored only scripts/release.py before committing.

Worth flagging what else rode along, since it was not just the version: uv.lock also carried a lockfile-format migration — revision = 1 -> 2 with upload-time metadata added throughout — because my local uv (0.7.12) is newer than the one that wrote the committed lockfile. That accounts for the ~1960-line lock diff and would have landed a format upgrade on dev under a release-tooling commit. Everything is back to 0.4.0-beta.2 and the diff against dev is now the 8 tooling files only.

Since the test was the root cause rather than the symptom, tests/test_release.py now has an autouse fixture making run and capture hard failures. These tests exercise release decisions, not the commands they authorize, so a guard that stops working should fail with "test reached a real subprocess" rather than quietly corrupting the checkout. I re-ran the identical mutation to confirm: it fails loudly now and uv.lock and the version files are untouched.

One consequence for whoever cuts the alpha: bump_version.py runs uv lock as part of every bump, so the release commit will pick up that same revision = 2 migration unless it is cut with a uv matching the committed lockfile. Probably worth doing the format bump deliberately in its own PR rather than having it ride the first alpha.

@mocha06
mocha06 requested a review from adriannoes July 31, 2026 19:00

@adriannoes adriannoes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mocha06
mocha06 merged commit c74c84a into dev Jul 31, 2026
2 checks passed
@adriannoes
adriannoes deleted the rc-dev/feat/release-alpha-from-dev branch July 31, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants