-
Notifications
You must be signed in to change notification settings - Fork 116
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
Open
ajbozarth
wants to merge
10
commits into
generative-computing:main
Choose a base branch
from
ajbozarth:feat/release-branch-workflow
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
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,226 @@ | ||
| #!/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 os | ||
| 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 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) | ||
| # Override UV_FROZEN inherited from the workflow env: frozen mode rejects | ||
| # lockfile updates, but every bump changes the package entry. | ||
| subprocess.run( | ||
| ["uv", "lock", "--upgrade-package", "mellea"], | ||
| cwd=REPO_ROOT, | ||
| check=True, | ||
| env={**os.environ, "UV_FROZEN": "0"}, | ||
| ) | ||
| subprocess.run( | ||
| ["git", "add", "pyproject.toml", "uv.lock"], cwd=REPO_ROOT, check=True | ||
| ) | ||
| subprocess.run( | ||
| ["git", "commit", "-m", f"release: bump version to {next_version} [skip ci]"], | ||
| cwd=REPO_ROOT, | ||
| check=True, | ||
| ) | ||
|
|
||
| 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,94 @@ | ||
| #!/bin/bash | ||
| # Cut a release branch off main and apply matched version bumps to both branches. | ||
| # | ||
| # Expected state before running: | ||
| # - Checked out on main with a clean working tree | ||
| # - pyproject.toml version matches X.Y.0.devN | ||
| # - No existing tag v{X.Y.0rc0} or branch release/vX.Y | ||
| # | ||
| # Produces: | ||
| # - release/vX.Y branch at X.Y.0rc0, pushed to origin | ||
| # - main bumped to X.(Y+1).0.dev0, pushed to origin | ||
| # | ||
| # Env: | ||
| # CONFIRM_MINOR (optional) — if set, must match X.Y derived from pyproject. | ||
|
|
||
| set -eu | ||
| set -x | ||
|
|
||
| CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) | ||
| if [ "${CURRENT_BRANCH}" != "main" ]; then | ||
| >&2 echo "error: must be run from main, got ${CURRENT_BRANCH}" | ||
| exit 2 | ||
| fi | ||
|
|
||
| if [ -n "$(git status --porcelain)" ]; then | ||
| >&2 echo "error: working tree is not clean" | ||
| exit 2 | ||
| fi | ||
|
|
||
| # Read the current version from pyproject.toml. | ||
| CURRENT_VERSION=$(uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version) | ||
|
|
||
| # Expected shape: X.Y.0.devN | ||
| if ! [[ "${CURRENT_VERSION}" =~ ^([0-9]+)\.([0-9]+)\.0\.dev([0-9]+)$ ]]; then | ||
| >&2 echo "error: pyproject version ${CURRENT_VERSION} does not match X.Y.0.devN" | ||
| exit 2 | ||
| fi | ||
| MAJOR="${BASH_REMATCH[1]}" | ||
| MINOR="${BASH_REMATCH[2]}" | ||
|
|
||
| if [ -n "${CONFIRM_MINOR:-}" ]; then | ||
| if [ "${CONFIRM_MINOR}" != "${MAJOR}.${MINOR}" ]; then | ||
| >&2 echo "error: CONFIRM_MINOR=${CONFIRM_MINOR} does not match pyproject minor ${MAJOR}.${MINOR}" | ||
| exit 2 | ||
| fi | ||
| fi | ||
|
|
||
| RELEASE_BRANCH="release/v${MAJOR}.${MINOR}" | ||
| RC_VERSION="${MAJOR}.${MINOR}.0rc0" | ||
| RC_TAG="v${RC_VERSION}" | ||
| NEXT_MINOR=$((MINOR + 1)) | ||
| NEXT_DEV_VERSION="${MAJOR}.${NEXT_MINOR}.0.dev0" | ||
|
|
||
| # Refuse if tag or branch already exists (local or remote). | ||
| git fetch origin --tags --prune | ||
| if git rev-parse --verify "refs/tags/${RC_TAG}" >/dev/null 2>&1; then | ||
| >&2 echo "error: tag ${RC_TAG} already exists" | ||
| exit 2 | ||
| fi | ||
| if git rev-parse --verify "refs/heads/${RELEASE_BRANCH}" >/dev/null 2>&1 \ | ||
| || git rev-parse --verify "refs/remotes/origin/${RELEASE_BRANCH}" >/dev/null 2>&1; then | ||
| >&2 echo "error: branch ${RELEASE_BRANCH} already exists" | ||
| exit 2 | ||
| fi | ||
|
|
||
| git config --global user.name 'github-actions[bot]' | ||
| git config --global user.email 'github-actions[bot]@users.noreply.github.com' | ||
|
|
||
| # Create the release branch and set the rc version there. | ||
| git checkout -b "${RELEASE_BRANCH}" | ||
| uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version "${RC_VERSION}" | ||
| UV_FROZEN=0 uv lock --upgrade-package mellea | ||
| git add pyproject.toml uv.lock | ||
| git commit -m "release: cut v${MAJOR}.${MINOR} branch at ${RC_VERSION} [skip ci]" | ||
| git push origin "${RELEASE_BRANCH}" | ||
|
|
||
| # Publish rc0 via release.sh — tag-only when PUBLISH_PRERELEASES is disabled, | ||
| # full prerelease flow when enabled. | ||
| RELEASE_BRANCH="${RELEASE_BRANCH}" "$(dirname "$0")/release.sh" | ||
|
|
||
| # Back to main and bump to the next dev version. | ||
| git checkout main | ||
| uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version "${NEXT_DEV_VERSION}" | ||
| UV_FROZEN=0 uv lock --upgrade-package mellea | ||
| git add pyproject.toml uv.lock | ||
| git commit -m "chore: bump main to ${NEXT_DEV_VERSION} [skip ci]" | ||
| git push origin main | ||
|
|
||
| set +x | ||
| echo "" | ||
| echo "Cut ${RELEASE_BRANCH} at ${RC_VERSION}" | ||
| echo "Bumped main to ${NEXT_DEV_VERSION}" | ||
| echo "" | ||
| echo "Next step: dispatch the Publish release workflow against ${RELEASE_BRANCH} with bump_type=rc to produce the next rc (rc1)." |
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.