-
Notifications
You must be signed in to change notification settings - Fork 117
feat: implement release-branch workflow #1076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ajbozarth
merged 10 commits into
generative-computing:main
from
ajbozarth:feat/release-branch-workflow
May 21, 2026
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
9bb71b9
feat: implement release-branch workflow (#1005)
ajbozarth bded76c
fix(release): address review feedback on release-branch workflow
ajbozarth a209e23
refactor(release): drop cherry-pick, gate prerelease publishing
ajbozarth c7da42f
docs(release): restructure RELEASE.md as operator playbook
ajbozarth 41427da
docs(release): replace ambiguous "CD" with concrete workflow names
ajbozarth e85842c
Merge branch 'main' into feat/release-branch-workflow
ajbozarth 1e0b4e0
chore: bump dev version to 0.7.0.dev0
ajbozarth 6637cd3
refactor: inline run helper in bump_version.py
ajbozarth 901a127
docs: restructure RELEASE.md as step-by-step operator guide
ajbozarth 0709a38
docs: number step headings in RELEASE.md release flows
ajbozarth File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| #!/usr/bin/env python3 | ||
| """Compute and commit the next release version. | ||
|
|
||
| Reads the current version from pyproject.toml, computes the next version per | ||
| the requested mode, writes it back, refreshes uv.lock, and commits. The | ||
| computed version is printed to stdout for callers to capture. | ||
|
|
||
| Modes: | ||
| rc — X.Y.ZrcN -> X.Y.Zrc(N+1) | ||
| final — X.Y.0rcN -> X.Y.0 (first final of the minor) | ||
| patch-rc — X.Y.Z -> X.Y.(Z+1)rc0 | X.Y.(Z+1)rcN -> X.Y.(Z+1)rc(N+1) | ||
| patch-final — X.Y.ZrcN (Z>0) -> X.Y.Z (promote patch rc to final) | ||
| dev — X.Y.Z.devN -> X.Y.Z.dev(N+1) (main-only) | ||
|
|
||
| `dev` mode runs on `main` and iterates its .devN counter. All other modes | ||
| run on `release/v*` branches. | ||
|
|
||
| With --dry-run the script prints the proposed version and exits without | ||
| writing or committing. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import re | ||
| import subprocess | ||
| import sys | ||
| import tomllib | ||
| from pathlib import Path | ||
|
|
||
| from packaging.version import Version | ||
|
|
||
| REPO_ROOT = Path(__file__).resolve().parents[2] | ||
| PYPROJECT = REPO_ROOT / "pyproject.toml" | ||
|
|
||
|
|
||
| def read_current_version() -> Version: | ||
| with PYPROJECT.open("rb") as f: | ||
| data = tomllib.load(f) | ||
| raw = data["project"]["version"] | ||
| return Version(raw) | ||
|
|
||
|
|
||
| def existing_tags() -> set[str]: | ||
| out = subprocess.run( | ||
| ["git", "tag", "--list", "v*"], | ||
| cwd=REPO_ROOT, | ||
| capture_output=True, | ||
| text=True, | ||
| check=True, | ||
| ) | ||
| return {line.strip() for line in out.stdout.splitlines() if line.strip()} | ||
|
|
||
|
|
||
| def current_branch() -> str: | ||
| out = subprocess.run( | ||
| ["git", "rev-parse", "--abbrev-ref", "HEAD"], | ||
| cwd=REPO_ROOT, | ||
| capture_output=True, | ||
| text=True, | ||
| check=True, | ||
| ) | ||
| return out.stdout.strip() | ||
|
|
||
|
|
||
| def compute_next(current: Version, mode: str) -> Version: | ||
| """Compute the next version per mode. Raises ValueError on disallowed transitions.""" | ||
| major, minor, patch = ( | ||
| current.release[0], | ||
| current.release[1], | ||
| (current.release[2] if len(current.release) > 2 else 0), | ||
| ) | ||
|
|
||
| if mode == "dev": | ||
| if current.dev is None: | ||
| raise ValueError( | ||
| f"mode=dev requires current version to be a .dev release; got {current}." | ||
| ) | ||
| if current.pre is not None: | ||
| raise ValueError( | ||
| f"mode=dev does not support .devN combined with a pre-release " | ||
| f"segment; got {current}." | ||
| ) | ||
| return Version(f"{major}.{minor}.{patch}.dev{current.dev + 1}") | ||
|
|
||
| if current.dev is not None: | ||
| raise ValueError( | ||
| f"Current version {current} is a .dev release; mode {mode!r} only " | ||
| "operates on release branches (rc/final). Ran on the wrong branch?" | ||
| ) | ||
|
|
||
| if mode == "rc": | ||
| if current.pre is None or current.pre[0] != "rc": | ||
| raise ValueError( | ||
| f"mode=rc requires current version to be an rc; got {current}. " | ||
| "If this is a final, use mode=patch-rc to start a patch cycle." | ||
| ) | ||
| return Version(f"{major}.{minor}.{patch}rc{current.pre[1] + 1}") | ||
|
|
||
| if mode == "final": | ||
| if current.pre is None or current.pre[0] != "rc": | ||
| raise ValueError( | ||
| f"mode=final requires current version to be an rc; got {current}." | ||
| ) | ||
| if patch != 0: | ||
| raise ValueError( | ||
| f"mode=final is for promoting minor rcs (X.Y.0rcN -> X.Y.0); " | ||
| f"got patch version {current}. Use mode=patch-final for patches." | ||
| ) | ||
| return Version(f"{major}.{minor}.{patch}") | ||
|
|
||
| if mode == "patch-rc": | ||
| if current.pre is None: | ||
| return Version(f"{major}.{minor}.{patch + 1}rc0") | ||
| if current.pre[0] != "rc": | ||
| raise ValueError(f"Unexpected pre-release segment in {current}") | ||
| if patch == 0: | ||
| raise ValueError( | ||
| f"mode=patch-rc requires an existing final or patch-rc; got " | ||
| f"{current} which is a minor rc. Use mode=rc to iterate minor rcs." | ||
| ) | ||
| return Version(f"{major}.{minor}.{patch}rc{current.pre[1] + 1}") | ||
|
|
||
| if mode == "patch-final": | ||
| if current.pre is None or current.pre[0] != "rc": | ||
| raise ValueError( | ||
| f"mode=patch-final requires current to be a patch rc; got {current}." | ||
| ) | ||
| if patch == 0: | ||
| raise ValueError( | ||
| f"mode=patch-final is for patches (Z>0); got {current}. " | ||
| "Use mode=final to promote a minor rc." | ||
| ) | ||
| return Version(f"{major}.{minor}.{patch}") | ||
|
|
||
| raise ValueError(f"Unknown mode: {mode!r}") | ||
|
|
||
|
|
||
| def write_pyproject(new_version: Version) -> None: | ||
| content = PYPROJECT.read_text() | ||
| pattern = re.compile(r'^(version\s*=\s*")[^"]+(")', re.MULTILINE) | ||
| new_content, n = pattern.subn(rf"\g<1>{new_version}\g<2>", content, count=1) | ||
| if n != 1: | ||
| raise RuntimeError("Failed to locate version line in pyproject.toml") | ||
| PYPROJECT.write_text(new_content) | ||
|
|
||
|
|
||
| def run(cmd: list[str]) -> None: | ||
| subprocess.run(cmd, cwd=REPO_ROOT, check=True) | ||
|
ajbozarth marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def main() -> int: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument( | ||
| "--mode", | ||
| required=True, | ||
| choices=["rc", "final", "patch-rc", "patch-final", "dev"], | ||
| ) | ||
| parser.add_argument( | ||
| "--dry-run", | ||
| action="store_true", | ||
| help="Print the proposed next version and exit without writing or committing.", | ||
| ) | ||
| parser.add_argument( | ||
| "--skip-branch-check", | ||
| action="store_true", | ||
| help="Skip the branch assertion. For local testing only.", | ||
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| if not args.skip_branch_check: | ||
| branch = current_branch() | ||
| if args.mode == "dev": | ||
| if branch != "main": | ||
| print( | ||
| f"error: mode=dev must run on main; current branch is {branch!r}", | ||
| file=sys.stderr, | ||
| ) | ||
| return 2 | ||
| elif not branch.startswith("release/v"): | ||
| print( | ||
| f"error: mode={args.mode} must run on a release/v* branch; " | ||
| f"current is {branch!r}", | ||
| file=sys.stderr, | ||
| ) | ||
| return 2 | ||
|
|
||
| current = read_current_version() | ||
| try: | ||
| next_version = compute_next(current, args.mode) | ||
| except ValueError as e: | ||
| print(f"error: {e}", file=sys.stderr) | ||
| return 2 | ||
|
|
||
| tag = f"v{next_version}" | ||
| if tag in existing_tags(): | ||
| print( | ||
| f"error: tag {tag} already exists; refusing to overwrite", file=sys.stderr | ||
| ) | ||
| return 2 | ||
|
|
||
| if args.dry_run: | ||
| print(next_version) | ||
| return 0 | ||
|
|
||
| write_pyproject(next_version) | ||
| run(["uv", "lock", "--upgrade-package", "mellea"]) | ||
| run(["git", "add", "pyproject.toml", "uv.lock"]) | ||
|
ajbozarth marked this conversation as resolved.
Outdated
|
||
| run(["git", "commit", "-m", f"release: bump version to {next_version} [skip ci]"]) | ||
|
|
||
| print(next_version) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| #!/bin/bash | ||
| # Cherry-pick one or more commits from main onto a release branch, preserving | ||
| # original merge order via topological sort. | ||
| # | ||
| # Usage: | ||
| # cherry_pick_to_release.sh <release-branch> <sha> [<sha> ...] | ||
| # | ||
| # Example: | ||
| # cherry_pick_to_release.sh release/v0.6 abc1234 def5678 | ||
| # | ||
| # Behavior: | ||
| # 1. Checks out the target release branch and fetches origin. | ||
| # 2. Validates every SHA is an ancestor of origin/main and not already on | ||
| # the release branch. | ||
| # 3. Topologically sorts the provided SHAs by their position in | ||
| # git log origin/main (oldest first), so the operator can pass SHAs in | ||
| # any order and they apply in original merge order. | ||
| # 4. Runs git cherry-pick -x for each SHA in sorted order. | ||
| # 5. On conflict, stops and prints a resolution playbook. | ||
| # 6. On success, either pushes to origin (when AUTO_PUSH=1, set by the | ||
| # CI workflow) or prints the push command for the operator to run. | ||
|
|
||
| set -eu | ||
|
|
||
|
ajbozarth marked this conversation as resolved.
Outdated
|
||
| if [ "$#" -lt 2 ]; then | ||
| >&2 echo "usage: $0 <release-branch> <sha> [<sha> ...]" | ||
| exit 2 | ||
| fi | ||
|
|
||
| RELEASE_BRANCH="$1" | ||
| shift | ||
|
|
||
| if ! [[ "${RELEASE_BRANCH}" =~ ^release/v ]]; then | ||
| >&2 echo "error: target branch ${RELEASE_BRANCH} does not match release/v*" | ||
| exit 2 | ||
| fi | ||
|
|
||
| if [ -n "$(git status --porcelain)" ]; then | ||
| >&2 echo "error: working tree is not clean" | ||
| exit 2 | ||
| fi | ||
|
|
||
| git fetch origin --tags --prune | ||
|
|
||
| # Ensure the release branch exists on origin. | ||
| if ! git rev-parse --verify "refs/remotes/origin/${RELEASE_BRANCH}" >/dev/null 2>&1; then | ||
| >&2 echo "error: origin/${RELEASE_BRANCH} does not exist" | ||
| exit 2 | ||
| fi | ||
|
|
||
| # Checkout the release branch tracking origin. | ||
| if git rev-parse --verify "refs/heads/${RELEASE_BRANCH}" >/dev/null 2>&1; then | ||
| git checkout "${RELEASE_BRANCH}" | ||
| git reset --hard "origin/${RELEASE_BRANCH}" | ||
| else | ||
| git checkout -b "${RELEASE_BRANCH}" "origin/${RELEASE_BRANCH}" | ||
| fi | ||
|
|
||
| # Validate each SHA: | ||
| # - Must resolve to a commit. | ||
| # - Must be an ancestor of origin/main (ie, merged). | ||
| # - Must NOT be already on the release branch. | ||
| for sha in "$@"; do | ||
| if ! git rev-parse --verify "${sha}^{commit}" >/dev/null 2>&1; then | ||
| >&2 echo "error: ${sha} is not a commit" | ||
| exit 2 | ||
| fi | ||
| if ! git merge-base --is-ancestor "${sha}" origin/main; then | ||
| >&2 echo "error: ${sha} is not an ancestor of origin/main (not yet merged?)" | ||
| exit 2 | ||
| fi | ||
| if git merge-base --is-ancestor "${sha}" HEAD; then | ||
| >&2 echo "error: ${sha} is already on ${RELEASE_BRANCH}" | ||
| exit 2 | ||
| fi | ||
| done | ||
|
|
||
| # Topologically sort SHAs by their position in git log origin/main (oldest first). | ||
| # git log --reverse lists commits in chronological (merge) order; we filter to | ||
| # just the SHAs we care about by streaming through the log and printing only | ||
| # matches. | ||
| SORTED_SHAS=$( | ||
| git log --reverse --format='%H' origin/main \ | ||
| | while read -r commit; do | ||
| for sha in "$@"; do | ||
| short=$(git rev-parse --short "${sha}") | ||
| full=$(git rev-parse "${sha}") | ||
|
ajbozarth marked this conversation as resolved.
Outdated
|
||
| if [ "${commit}" = "${full}" ]; then | ||
| echo "${full}" | ||
| break | ||
| fi | ||
| done | ||
| done | ||
| ) | ||
|
|
||
| if [ -z "${SORTED_SHAS}" ]; then | ||
| >&2 echo "error: no SHAs resolved to commits on origin/main (internal error)" | ||
| exit 2 | ||
| fi | ||
|
|
||
| echo "Cherry-picking (in merge order):" | ||
| echo "${SORTED_SHAS}" | while read -r sha; do | ||
| echo " $(git log -1 --format='%h %s' "${sha}")" | ||
| done | ||
|
|
||
| # Apply the cherry-picks. | ||
| CONFLICTED=0 | ||
| while read -r sha; do | ||
| if ! git cherry-pick -x "${sha}"; then | ||
| CONFLICTED=1 | ||
| break | ||
| fi | ||
| done <<< "${SORTED_SHAS}" | ||
|
|
||
| if [ "${CONFLICTED}" -eq 1 ]; then | ||
| cat >&2 <<EOF | ||
|
|
||
| ============================================================================= | ||
| Cherry-pick hit a conflict on $(git rev-parse --short CHERRY_PICK_HEAD 2>/dev/null || echo "a commit"). | ||
|
|
||
| To resolve locally: | ||
| 1. Clone the repo (if you are not already local) and check out ${RELEASE_BRANCH}. | ||
| 2. Re-run this script with the same SHAs to reach the same state. | ||
| 3. Resolve the conflicted files, then: | ||
| git add <resolved-files> | ||
| git cherry-pick --continue | ||
| 4. Push to origin (requires push access / bypass rights): | ||
| git push origin ${RELEASE_BRANCH} | ||
|
ajbozarth marked this conversation as resolved.
Outdated
|
||
|
|
||
| Abort with: | ||
| git cherry-pick --abort | ||
| ============================================================================= | ||
| EOF | ||
| exit 1 | ||
| fi | ||
|
|
||
| if [ "${AUTO_PUSH:-0}" = "1" ]; then | ||
| git push origin "${RELEASE_BRANCH}" | ||
| echo "" | ||
| echo "Pushed to origin/${RELEASE_BRANCH}" | ||
| else | ||
| echo "" | ||
| echo "Cherry-picks applied locally on ${RELEASE_BRANCH}." | ||
| echo "To push: git push origin ${RELEASE_BRANCH}" | ||
| fi | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.