Skip to content

Commit 9bb71b9

Browse files
committed
feat: implement release-branch workflow (#1005)
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>
1 parent 79769a3 commit 9bb71b9

16 files changed

Lines changed: 1326 additions & 513 deletions

.github/scripts/bump_version.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#!/usr/bin/env python3
2+
"""Compute and commit the next release version.
3+
4+
Reads the current version from pyproject.toml, computes the next version per
5+
the requested mode, writes it back, refreshes uv.lock, and commits. The
6+
computed version is printed to stdout for callers to capture.
7+
8+
Modes:
9+
rc — X.Y.ZrcN -> X.Y.Zrc(N+1)
10+
final — X.Y.0rcN -> X.Y.0 (first final of the minor)
11+
patch-rc — X.Y.Z -> X.Y.(Z+1)rc0 | X.Y.(Z+1)rcN -> X.Y.(Z+1)rc(N+1)
12+
patch-final — X.Y.ZrcN (Z>0) -> X.Y.Z (promote patch rc to final)
13+
dev — X.Y.Z.devN -> X.Y.Z.dev(N+1) (main-only)
14+
15+
`dev` mode runs on `main` and iterates its .devN counter. All other modes
16+
run on `release/v*` branches.
17+
18+
With --dry-run the script prints the proposed version and exits without
19+
writing or committing.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import argparse
25+
import re
26+
import subprocess
27+
import sys
28+
import tomllib
29+
from pathlib import Path
30+
31+
from packaging.version import Version
32+
33+
REPO_ROOT = Path(__file__).resolve().parents[2]
34+
PYPROJECT = REPO_ROOT / "pyproject.toml"
35+
36+
37+
def read_current_version() -> Version:
38+
with PYPROJECT.open("rb") as f:
39+
data = tomllib.load(f)
40+
raw = data["project"]["version"]
41+
return Version(raw)
42+
43+
44+
def existing_tags() -> set[str]:
45+
out = subprocess.run(
46+
["git", "tag", "--list", "v*"],
47+
cwd=REPO_ROOT,
48+
capture_output=True,
49+
text=True,
50+
check=True,
51+
)
52+
return {line.strip() for line in out.stdout.splitlines() if line.strip()}
53+
54+
55+
def current_branch() -> str:
56+
out = subprocess.run(
57+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
58+
cwd=REPO_ROOT,
59+
capture_output=True,
60+
text=True,
61+
check=True,
62+
)
63+
return out.stdout.strip()
64+
65+
66+
def compute_next(current: Version, mode: str) -> Version:
67+
"""Compute the next version per mode. Raises ValueError on disallowed transitions."""
68+
major, minor, patch = (
69+
current.release[0],
70+
current.release[1],
71+
(current.release[2] if len(current.release) > 2 else 0),
72+
)
73+
74+
if mode == "dev":
75+
if current.dev is None:
76+
raise ValueError(
77+
f"mode=dev requires current version to be a .dev release; got {current}."
78+
)
79+
if current.pre is not None:
80+
raise ValueError(
81+
f"mode=dev does not support .devN combined with a pre-release "
82+
f"segment; got {current}."
83+
)
84+
return Version(f"{major}.{minor}.{patch}.dev{current.dev + 1}")
85+
86+
if current.dev is not None:
87+
raise ValueError(
88+
f"Current version {current} is a .dev release; mode {mode!r} only "
89+
"operates on release branches (rc/final). Ran on the wrong branch?"
90+
)
91+
92+
if mode == "rc":
93+
if current.pre is None or current.pre[0] != "rc":
94+
raise ValueError(
95+
f"mode=rc requires current version to be an rc; got {current}. "
96+
"If this is a final, use mode=patch-rc to start a patch cycle."
97+
)
98+
return Version(f"{major}.{minor}.{patch}rc{current.pre[1] + 1}")
99+
100+
if mode == "final":
101+
if current.pre is None or current.pre[0] != "rc":
102+
raise ValueError(
103+
f"mode=final requires current version to be an rc; got {current}."
104+
)
105+
if patch != 0:
106+
raise ValueError(
107+
f"mode=final is for promoting minor rcs (X.Y.0rcN -> X.Y.0); "
108+
f"got patch version {current}. Use mode=patch-final for patches."
109+
)
110+
return Version(f"{major}.{minor}.{patch}")
111+
112+
if mode == "patch-rc":
113+
if current.pre is None:
114+
return Version(f"{major}.{minor}.{patch + 1}rc0")
115+
if current.pre[0] != "rc":
116+
raise ValueError(f"Unexpected pre-release segment in {current}")
117+
if patch == 0:
118+
raise ValueError(
119+
f"mode=patch-rc requires an existing final or patch-rc; got "
120+
f"{current} which is a minor rc. Use mode=rc to iterate minor rcs."
121+
)
122+
return Version(f"{major}.{minor}.{patch}rc{current.pre[1] + 1}")
123+
124+
if mode == "patch-final":
125+
if current.pre is None or current.pre[0] != "rc":
126+
raise ValueError(
127+
f"mode=patch-final requires current to be a patch rc; got {current}."
128+
)
129+
if patch == 0:
130+
raise ValueError(
131+
f"mode=patch-final is for patches (Z>0); got {current}. "
132+
"Use mode=final to promote a minor rc."
133+
)
134+
return Version(f"{major}.{minor}.{patch}")
135+
136+
raise ValueError(f"Unknown mode: {mode!r}")
137+
138+
139+
def write_pyproject(new_version: Version) -> None:
140+
content = PYPROJECT.read_text()
141+
pattern = re.compile(r'^(version\s*=\s*")[^"]+(")', re.MULTILINE)
142+
new_content, n = pattern.subn(rf"\g<1>{new_version}\g<2>", content, count=1)
143+
if n != 1:
144+
raise RuntimeError("Failed to locate version line in pyproject.toml")
145+
PYPROJECT.write_text(new_content)
146+
147+
148+
def run(cmd: list[str]) -> None:
149+
subprocess.run(cmd, cwd=REPO_ROOT, check=True)
150+
151+
152+
def main() -> int:
153+
parser = argparse.ArgumentParser(description=__doc__)
154+
parser.add_argument(
155+
"--mode",
156+
required=True,
157+
choices=["rc", "final", "patch-rc", "patch-final", "dev"],
158+
)
159+
parser.add_argument(
160+
"--dry-run",
161+
action="store_true",
162+
help="Print the proposed next version and exit without writing or committing.",
163+
)
164+
parser.add_argument(
165+
"--skip-branch-check",
166+
action="store_true",
167+
help="Skip the branch assertion. For local testing only.",
168+
)
169+
args = parser.parse_args()
170+
171+
if not args.skip_branch_check:
172+
branch = current_branch()
173+
if args.mode == "dev":
174+
if branch != "main":
175+
print(
176+
f"error: mode=dev must run on main; current branch is {branch!r}",
177+
file=sys.stderr,
178+
)
179+
return 2
180+
elif not branch.startswith("release/v"):
181+
print(
182+
f"error: mode={args.mode} must run on a release/v* branch; "
183+
f"current is {branch!r}",
184+
file=sys.stderr,
185+
)
186+
return 2
187+
188+
current = read_current_version()
189+
try:
190+
next_version = compute_next(current, args.mode)
191+
except ValueError as e:
192+
print(f"error: {e}", file=sys.stderr)
193+
return 2
194+
195+
tag = f"v{next_version}"
196+
if tag in existing_tags():
197+
print(
198+
f"error: tag {tag} already exists; refusing to overwrite", file=sys.stderr
199+
)
200+
return 2
201+
202+
if args.dry_run:
203+
print(next_version)
204+
return 0
205+
206+
write_pyproject(next_version)
207+
run(["uv", "lock", "--upgrade-package", "mellea"])
208+
run(["git", "add", "pyproject.toml", "uv.lock"])
209+
run(["git", "commit", "-m", f"release: bump version to {next_version} [skip ci]"])
210+
211+
print(next_version)
212+
return 0
213+
214+
215+
if __name__ == "__main__":
216+
sys.exit(main())
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/bin/bash
2+
# Cherry-pick one or more commits from main onto a release branch, preserving
3+
# original merge order via topological sort.
4+
#
5+
# Usage:
6+
# cherry_pick_to_release.sh <release-branch> <sha> [<sha> ...]
7+
#
8+
# Example:
9+
# cherry_pick_to_release.sh release/v0.6 abc1234 def5678
10+
#
11+
# Behavior:
12+
# 1. Checks out the target release branch and fetches origin.
13+
# 2. Validates every SHA is an ancestor of origin/main and not already on
14+
# the release branch.
15+
# 3. Topologically sorts the provided SHAs by their position in
16+
# git log origin/main (oldest first), so the operator can pass SHAs in
17+
# any order and they apply in original merge order.
18+
# 4. Runs git cherry-pick -x for each SHA in sorted order.
19+
# 5. On conflict, stops and prints a resolution playbook.
20+
# 6. On success, either pushes to origin (when AUTO_PUSH=1, set by the
21+
# CI workflow) or prints the push command for the operator to run.
22+
23+
set -eu
24+
25+
if [ "$#" -lt 2 ]; then
26+
>&2 echo "usage: $0 <release-branch> <sha> [<sha> ...]"
27+
exit 2
28+
fi
29+
30+
RELEASE_BRANCH="$1"
31+
shift
32+
33+
if ! [[ "${RELEASE_BRANCH}" =~ ^release/v ]]; then
34+
>&2 echo "error: target branch ${RELEASE_BRANCH} does not match release/v*"
35+
exit 2
36+
fi
37+
38+
if [ -n "$(git status --porcelain)" ]; then
39+
>&2 echo "error: working tree is not clean"
40+
exit 2
41+
fi
42+
43+
git fetch origin --tags --prune
44+
45+
# Ensure the release branch exists on origin.
46+
if ! git rev-parse --verify "refs/remotes/origin/${RELEASE_BRANCH}" >/dev/null 2>&1; then
47+
>&2 echo "error: origin/${RELEASE_BRANCH} does not exist"
48+
exit 2
49+
fi
50+
51+
# Checkout the release branch tracking origin.
52+
if git rev-parse --verify "refs/heads/${RELEASE_BRANCH}" >/dev/null 2>&1; then
53+
git checkout "${RELEASE_BRANCH}"
54+
git reset --hard "origin/${RELEASE_BRANCH}"
55+
else
56+
git checkout -b "${RELEASE_BRANCH}" "origin/${RELEASE_BRANCH}"
57+
fi
58+
59+
# Validate each SHA:
60+
# - Must resolve to a commit.
61+
# - Must be an ancestor of origin/main (ie, merged).
62+
# - Must NOT be already on the release branch.
63+
for sha in "$@"; do
64+
if ! git rev-parse --verify "${sha}^{commit}" >/dev/null 2>&1; then
65+
>&2 echo "error: ${sha} is not a commit"
66+
exit 2
67+
fi
68+
if ! git merge-base --is-ancestor "${sha}" origin/main; then
69+
>&2 echo "error: ${sha} is not an ancestor of origin/main (not yet merged?)"
70+
exit 2
71+
fi
72+
if git merge-base --is-ancestor "${sha}" HEAD; then
73+
>&2 echo "error: ${sha} is already on ${RELEASE_BRANCH}"
74+
exit 2
75+
fi
76+
done
77+
78+
# Topologically sort SHAs by their position in git log origin/main (oldest first).
79+
# git log --reverse lists commits in chronological (merge) order; we filter to
80+
# just the SHAs we care about by streaming through the log and printing only
81+
# matches.
82+
SORTED_SHAS=$(
83+
git log --reverse --format='%H' origin/main \
84+
| while read -r commit; do
85+
for sha in "$@"; do
86+
short=$(git rev-parse --short "${sha}")
87+
full=$(git rev-parse "${sha}")
88+
if [ "${commit}" = "${full}" ]; then
89+
echo "${full}"
90+
break
91+
fi
92+
done
93+
done
94+
)
95+
96+
if [ -z "${SORTED_SHAS}" ]; then
97+
>&2 echo "error: no SHAs resolved to commits on origin/main (internal error)"
98+
exit 2
99+
fi
100+
101+
echo "Cherry-picking (in merge order):"
102+
echo "${SORTED_SHAS}" | while read -r sha; do
103+
echo " $(git log -1 --format='%h %s' "${sha}")"
104+
done
105+
106+
# Apply the cherry-picks.
107+
CONFLICTED=0
108+
while read -r sha; do
109+
if ! git cherry-pick -x "${sha}"; then
110+
CONFLICTED=1
111+
break
112+
fi
113+
done <<< "${SORTED_SHAS}"
114+
115+
if [ "${CONFLICTED}" -eq 1 ]; then
116+
cat >&2 <<EOF
117+
118+
=============================================================================
119+
Cherry-pick hit a conflict on $(git rev-parse --short CHERRY_PICK_HEAD 2>/dev/null || echo "a commit").
120+
121+
To resolve locally:
122+
1. Clone the repo (if you are not already local) and check out ${RELEASE_BRANCH}.
123+
2. Re-run this script with the same SHAs to reach the same state.
124+
3. Resolve the conflicted files, then:
125+
git add <resolved-files>
126+
git cherry-pick --continue
127+
4. Push to origin (requires push access / bypass rights):
128+
git push origin ${RELEASE_BRANCH}
129+
130+
Abort with:
131+
git cherry-pick --abort
132+
=============================================================================
133+
EOF
134+
exit 1
135+
fi
136+
137+
if [ "${AUTO_PUSH:-0}" = "1" ]; then
138+
git push origin "${RELEASE_BRANCH}"
139+
echo ""
140+
echo "Pushed to origin/${RELEASE_BRANCH}"
141+
else
142+
echo ""
143+
echo "Cherry-picks applied locally on ${RELEASE_BRANCH}."
144+
echo "To push: git push origin ${RELEASE_BRANCH}"
145+
fi

0 commit comments

Comments
 (0)