-
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
|
||
|
|
||
| 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.
|
||
| run(["git", "commit", "-m", f"release: bump version to {next_version} [skip ci]"]) | ||
|
|
||
| print(next_version) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
| 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.
|
||||||||||||||||||||||||||||||||||||||
| 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.
|
||||||||||||||||||||||||||||||||||||||
| 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} | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+121
to
+128
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Step 2 ("Re-run this script with the same SHAs") doesn't actually work. When Reordered with an explicit abort step first:
Suggested change
Alternative is to detect |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: when
patch > 0, bothmode=rcandmode=patch-rcproduce the same output (X.Y.Zrc(N+1)).mode=rcquietly succeeds on patch rcs by accident. RELEASE.md's operator table only documentspatch-rcfor the patch cycle, so someone runningrcby muscle memory gets the right answer with no signal they've used the wrong mode.Consider explicitly rejecting
mode=rcwhenpatch != 0:There was a problem hiding this comment.
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=rcis 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.0rc1→0.6.0rc2) or a patch rc (0.6.1rc1→0.6.1rc2).mode=patch-rcis the transition mode for going from a final to the first rc of a patch cycle (0.6.0→0.6.1rc0). It can also iterate patch rcs after that, but its primary purpose is the transition.The error messages reflect that split:
mode=rcrejects non-rcs ("If this is a final, use mode=patch-rc to start a patch cycle"), andmode=patch-rcrejects 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 "runningrcagainst a patch rc that already exists," which is alsorc's job.Rejecting
rcwhenpatch != 0would 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 keeprcas the universal iterator.