Skip to content

feat: implement release-branch workflow#1076

Open
ajbozarth wants to merge 1 commit into
generative-computing:mainfrom
ajbozarth:feat/release-branch-workflow
Open

feat: implement release-branch workflow#1076
ajbozarth wants to merge 1 commit into
generative-computing:mainfrom
ajbozarth:feat/release-branch-workflow

Conversation

@ajbozarth
Copy link
Copy Markdown
Contributor

@ajbozarth ajbozarth commented May 13, 2026

Misc PR

Type of PR

  • Bug Fix
  • New Feature
  • Documentation
  • Other

Description

Replaces the cut-from-main release flow with a release-branch model. Every minor release gets a long-lived release/vX.Y branch carrying rcs and the final; main carries X.Y.0.devN for the next minor. Patches cherry-pick onto the existing release branch and go through their own rc cycle. See RELEASE.md for the full operator-facing documentation.

Adds four workflow_dispatch workflows (cut-release-branch, publish-release (renamed from cd.yml), cherry-pick-to-release, publish-dev-from-main), a bump_version.py helper with five PEP 440 transition modes, and a PUBLISH_PRERELEASES repo variable that gates PyPI uploads for rc/dev versions. Auth migrates from the mellea-auto-release GitHub App to GITHUB_TOKEN with inline permissions: blocks.

Admin actions required after merge are listed at the bottom of RELEASE.md.

Testing

  • Tests added to the respective file if code was changed
  • New code has 100% coverage if code as added
  • Ensure existing tests and github automation passes (a maintainer will kick off the github automation when the rest of the PR is populated)

End-to-end dry-run validated on ajbozarth/mellea fork: cut-release, cherry-pick, publish-dev-from-main, rc, final, and the explicit downstream-workflow dispatches (pypi.yml, docs-publish.yml, ci.yml) that work around GitHub's anti-loop rule for GITHUB_TOKEN-authored events.

Attribution

  • AI coding assistants used

Replaces the cut-from-main release flow with long-lived release/vX.Y
branches carrying rcs and finals. main carries X.Y.0.devN for the next
minor. Patches cherry-pick onto the existing release branch.

Adds four workflow_dispatch workflows: cut-release-branch, publish-release
(was cd.yml), cherry-pick-to-release, publish-dev-from-main. Adds
bump_version.py with five PEP 440 transition modes plus unit tests.

Prerelease publishing to PyPI is gated on PUBLISH_PRERELEASES (default
false). Auth migrates from the mellea-auto-release GitHub App to
GITHUB_TOKEN with inline permissions blocks.

See RELEASE.md for the full operator-facing flow.

Assisted-by: Claude Code
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
@ajbozarth ajbozarth requested a review from a team as a code owner May 13, 2026 22:37
@ajbozarth ajbozarth requested review from nrfulton and planetf1 May 13, 2026 22:37
@github-actions github-actions Bot added the enhancement New feature or request label May 13, 2026
@ajbozarth ajbozarth self-assigned this May 13, 2026
@psschwei
Copy link
Copy Markdown
Member

Stepping back to the original problem we had on the last release, where we had to basically pause new PR merges for a few days while the release was prepared, I think the main thing we want from a release branch is to let contributors keep merging to main while a release is being stabilized.

With that in mind, I want to push back a bit on the scope of this PR. I think most of the machinery here is solving an adjacent problem (formal PEP 440 rc/dev/patch lifecycle) rather than the merges-during-stabilization problem we ran into last time.

I think we could actually solve this with a simpler flow:

  1. When a release is ready to stabilize, create release/vX.Y from main using GitHub's UI (Branches → New branch from main)
  2. Stabilization fixes land on release/vX.Y via normal PRs targeting that branch, while regular development keeps happening on main
  3. When ready to publish, dispatch a single "Publish release" workflow against the release branch, with the target version typed in as an input (e.g. 0.6.0, or 0.6.1 for a later patch). The workflow handles the version bump, tag, GitHub Release, PyPI upload, changelog, and changelog-sync PR back to main.

I'd suggest splitting this in two: one PR for the release branch and a separate issue/PR for the PEP 440 flow. I think that would allow for more discussion but also let us close on the merge-during-release problem quicker.

Comment thread .github/scripts/cherry_pick_to_release.sh
Comment thread .github/scripts/cherry_pick_to_release.sh
Comment thread .github/scripts/bump_version.py
Comment thread .github/scripts/release.sh
Comment thread .github/scripts/release.sh
Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/pypi.yml
)

if mode == "rc":
if current.pre is None or current.pre[0] != "rc":
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: when patch > 0, both mode=rc and mode=patch-rc produce the same output (X.Y.Zrc(N+1)). mode=rc quietly succeeds on patch rcs by accident. RELEASE.md's operator table only documents patch-rc for the patch cycle, so someone running rc by muscle memory gets the right answer with no signal they've used the wrong mode.

Consider explicitly rejecting mode=rc when patch != 0:

if patch != 0:
    raise ValueError(
        f"mode=rc is for minor rcs (X.Y.0); got {current}. "
        "Use mode=patch-rc to iterate a patch rc."
    )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The overlap is intentional, I'll let Claude explain:

The two modes have different jobs:

  • mode=rc is the rc iterator — requires an existing rc and bumps the rc number. Works the same way regardless of whether it's a minor rc (0.6.0rc10.6.0rc2) or a patch rc (0.6.1rc10.6.1rc2).
  • mode=patch-rc is the transition mode for going from a final to the first rc of a patch cycle (0.6.00.6.1rc0). It can also iterate patch rcs after that, but its primary purpose is the transition.

The error messages reflect that split: mode=rc rejects non-rcs ("If this is a final, use mode=patch-rc to start a patch cycle"), and mode=patch-rc rejects minor rcs ("Use mode=rc to iterate minor rcs"). An operator running the wrong mode for the wrong context still gets a hard error — the only soft case is "running rc against a patch rc that already exists," which is also rc's job.

Rejecting rc when patch != 0 would break the design: there'd be no single mode that just iterates an rc, and operators would have to mentally branch on whether they're in a minor or patch cycle every time they bump. I'd rather keep rc as the universal iterator.

Comment on lines +79 to 81
# Pull the generated notes back locally to update the changelog.
REL_NOTES=$(mktemp)
gh release view "${TARGET_TAG_NAME}" --json body -q ".body" >> "${REL_NOTES}"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should have a discussion of what we want the release notes to encompass. If we cut release tags for rc1, devN, etc... I think these autogenerated release notes will be problematic.

Github auto-builds these notes with using PRs merged since the last release. I do not know if it accounts for standard semver things. If not, we will either have to do this ourselves or change our release tagging process documented here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This will be affected by the choices outlined in my refactor ideas below, but if we keep pre-release tags then I would just need to update the changelog workflow to compare to the last final release rather than the most recent one, a trivial change

Comment on lines +17 to +22
options:
- rc
- final
- patch-rc
- patch-final
- none
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why would we want to tag a rc0, etc... release (really anything but the final release for a v0.X.Y)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are more of a future proofing choice for supporting pre-releases on pypi when/if we decide to add those by enabling PUBLISH_PRERELEASES=true

@ajbozarth
Copy link
Copy Markdown
Contributor Author

I addressed @planetf1 technical review with a new commit and a response, as for @psschwei and @jakelorocco your review is the type of feedback that I wanted to drive the discussion at sync on Monday, in fact I had meant to open this as draft to make it clear this was just a proposal. In writing this I focused on making it full-featured so we could remove undesired features afterwards rather than needing to rework new functionally in, so I'm open to removing things to streamline it.

As such I'll have Claude detail out some refactoring ideas for discussion based on your comments and my opinions, we can then dig into these ideas both here and on Monday's call:


Refactor ideas for Monday

Four points to discuss. Items 1 and 2 are alternatives — pick at most one. Item 3 is orthogonal to both. Item 4 is the "split into multiple PRs" question. My current lean on each is in italics.

1. Drop speculative prerelease tagging. Currently we create git tags for every rc and dev (v0.6.0rc1, v0.6.0.dev3) even though PUBLISH_PRERELEASES defaults to false, so the tags exist but no PyPI upload happens unless an admin flips the flag.

  • Drop the tagging. No prerelease tags created until we actually decide to publish prereleases. If we later flip PUBLISH_PRERELEASES, we'd add tagging back at that point. Jake's auto-notes concern dissolves because the only tag that exists is the final. This is what I'd lean toward.
  • Keep the future-proofing tags. Already what the code does. Add --notes-start-tag <last-final> to gh release create for the final so auto-notes diff against the previous final, not the previous rc. ~5 lines of shell. Preserves the "every prerelease version has a corresponding git tag" invariant for when we eventually flip the flag.

2. Drop prereleases entirely. A more aggressive trim that supersedes #1: stabilization happens in-place on the release branch with no rc cycle, no .devN from main, no PyPI uploads of prereleases ever. Final ships when ready, version bumps once at the end. Since prereleases don't publish by default today, the day-one diff to current behavior is small — what we'd lose is the option to flip the flag later and start publishing prereleases. Users who want pre-stable code would install from a git ref. I'd argue against going this far — the prerelease infrastructure is built and validated; keeping it (with #1's tagging trim) is cheap.

3. Drop cherry-pick, switch to PRs targeting the release branch. This is Paul's proposal and is orthogonal to #1 and #2. The current PR's flow is "every change lands on main first; maintainers run a cherry-pick workflow to port selected commits onto the release branch." Paul's alternative: contributors open stabilization PRs directly against release/vX.Y.

What gets removed if we switch: cherry_pick_to_release.sh, cherry-pick-to-release.yml, the merge-order topological sort logic, and the operator playbook for resolving cherry-pick conflicts.

Tradeoffs:

Cherry-pick (current) PRs to release branch (Paul's)
Source of truth main is canonical; release branch is a curated subset both branches accept changes; drift possible
Contributor burden nothing new — open PRs against main as usual must know which branch to target; maintainers may need to redirect
Maintainer burden identify SHAs + dispatch cherry-pick workflow review release-branch PRs; manually port to main if needed
Fix-on-both case one PR to main, one cherry-pick dispatch two PRs, or one + manual port
Release-branch-only fix requires temporary land-on-main or script bypass natural — just PR against the release branch
New machinery ~150 lines of shell + a workflow none

I'd lean toward keeping cherry-pick. "Main is the source of truth" matches what most contributors already do, and the cherry-pick machinery is written and validated. The simplification Paul gets is real but not large.

4. Split into two PRs. @psschwei suggested splitting release-branch + cherry-pick into PR1 and the PEP 440 lifecycle into PR2. I'd push back on this. If we decide to keep prerelease versioning, it should land integrated, not split — and if we decide to remove it (per #2 above), there's nothing to split. Splitting creates an awkward intermediate state where the project has half a release model.

For illustration, the cleanest seam would be:

The seam isn't clean — both PRs edit bump_version.py, cut-release-branch.yml, release.sh, pypi.yml, and RELEASE.md. PR2 would partially undo and reshape what PR1 establishes. The end-to-end dry-run I did would need to be redone for PR1's narrower scope and again for PR2's reintegration.

The smaller-review-surface benefit Paul wants from a split, we can also get by trimming features inside this PR after Monday's discussion — without the integration churn.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support an actual release branch

4 participants