Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions .github/scripts/bump_version.py
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":
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.

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"])
Comment thread
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())
145 changes: 145 additions & 0 deletions .github/scripts/cherry_pick_to_release.sh
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

Comment thread
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}")
Comment thread
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
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.

Step 2 ("Re-run this script with the same SHAs") doesn't actually work. When git cherry-pick hits a conflict it leaves the working tree dirty (UU <file>), so the re-run hits the clean-tree check at line 38 and exits straight away.

Reordered with an explicit abort step first:

Suggested change
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}
To resolve locally:
1. Clone the repo (if you are not already local) and check out ${RELEASE_BRANCH}.
2. Abort the in-progress cherry-pick:
git cherry-pick --abort
3. Re-run this script with the same SHAs to reach the same conflict.
4. Resolve the conflicted files, then:
git add <resolved-files>
git cherry-pick --continue
5. Push to origin (requires push access / bypass rights):
git push origin ${RELEASE_BRANCH}

Alternative is to detect CHERRY_PICK_HEAD at the top of the script and skip the clean-tree check in that case, but the playbook fix is simpler.


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
Loading
Loading