From 9bb71b9c456d87aeb13edcbfba4f484b13775d36 Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Wed, 13 May 2026 17:35:01 -0500 Subject: [PATCH 1/9] 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 --- .github/scripts/bump_version.py | 216 ++++++++++++ .github/scripts/cherry_pick_to_release.sh | 145 ++++++++ .github/scripts/cut_release_branch.sh | 94 ++++++ .github/scripts/release.sh | 109 ++++-- .github/workflows/cd.yml | 86 ----- .github/workflows/cherry-pick-to-release.yml | 77 +++++ .github/workflows/ci.yml | 10 + .github/workflows/cut-release-branch.yml | 57 ++++ .github/workflows/docs-publish.yml | 75 ++++- .github/workflows/publish-dev-from-main.yml | 81 +++++ .github/workflows/publish-release.yml | 145 ++++++++ .github/workflows/pypi.yml | 31 +- RELEASE.md | 268 ++++++++++++--- pyproject.toml | 26 +- test/scripts/test_bump_version.py | 89 +++++ uv.lock | 330 +------------------ 16 files changed, 1326 insertions(+), 513 deletions(-) create mode 100755 .github/scripts/bump_version.py create mode 100755 .github/scripts/cherry_pick_to_release.sh create mode 100755 .github/scripts/cut_release_branch.sh delete mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/cherry-pick-to-release.yml create mode 100644 .github/workflows/cut-release-branch.yml create mode 100644 .github/workflows/publish-dev-from-main.yml create mode 100644 .github/workflows/publish-release.yml create mode 100644 test/scripts/test_bump_version.py diff --git a/.github/scripts/bump_version.py b/.github/scripts/bump_version.py new file mode 100755 index 000000000..27f69bcbf --- /dev/null +++ b/.github/scripts/bump_version.py @@ -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"]) + run(["git", "commit", "-m", f"release: bump version to {next_version} [skip ci]"]) + + print(next_version) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/cherry_pick_to_release.sh b/.github/scripts/cherry_pick_to_release.sh new file mode 100755 index 000000000..227b867a3 --- /dev/null +++ b/.github/scripts/cherry_pick_to_release.sh @@ -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 [ ...] +# +# 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 + +if [ "$#" -lt 2 ]; then + >&2 echo "usage: $0 [ ...]" + 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}") + 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 </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 + git cherry-pick --continue + 4. Push to origin (requires push access / bypass rights): + git push origin ${RELEASE_BRANCH} + +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 diff --git a/.github/scripts/cut_release_branch.sh b/.github/scripts/cut_release_branch.sh new file mode 100755 index 000000000..38806d3f4 --- /dev/null +++ b/.github/scripts/cut_release_branch.sh @@ -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)." diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 55f8295af..44a62f2fd 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -1,50 +1,115 @@ #!/bin/bash +# Publish a release from the currently checked-out branch. Uses the version +# already written to pyproject.toml; does not modify it. +# +# Env: +# RELEASE_BRANCH (required) — branch being published (e.g. release/v0.6). +# Guards against accidentally releasing from main. +# GH_TOKEN (required) — for gh release create / gh pr create +# GITHUB_REPOSITORY (required) — owner/repo, used for origin URL and links +# CHGLOG_FILE (optional) — path to changelog (default: CHANGELOG.md) +# ALLOW_MAIN_RELEASE=1 (optional) — bypass the main-branch guard +# +# Prereleases (rc, .dev): push a git tag. PyPI upload is handled by pypi.yml +# when the tag push fires, gated on PUBLISH_PRERELEASES. +# +# Finals: create a GitHub Release (tag + Release object + generated notes), +# append to the changelog on the release branch, and open a sync PR to main +# with the changelog delta. -set -e # trigger failure on error - do not remove! -set -x # display command on output +set -e +set -x -if [ -z "${TARGET_VERSION}" ]; then - >&2 echo "No TARGET_VERSION specified" - exit 1 +if [ -z "${RELEASE_BRANCH:-}" ]; then + >&2 echo "error: RELEASE_BRANCH env var is required" + exit 2 fi +if [ "${RELEASE_BRANCH}" = "main" ] && [ "${ALLOW_MAIN_RELEASE:-0}" != "1" ]; then + >&2 echo "error: refusing to release from main; dispatch against a release/v* branch" + exit 2 +fi + CHGLOG_FILE="${CHGLOG_FILE:-CHANGELOG.md}" -# update package version -uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version "${TARGET_VERSION}" -UV_FROZEN=0 uv lock --upgrade-package mellea +# Pull the version from pyproject.toml — authoritative after bump_version.py ran. +TARGET_VERSION=$(uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version) +TARGET_TAG_NAME="v${TARGET_VERSION}" + +# Detect prerelease shape (rc or .dev). +IS_PRERELEASE=0 +if [[ "${TARGET_VERSION}" == *rc* ]] || [[ "${TARGET_VERSION}" == *.dev* ]]; then + IS_PRERELEASE=1 +fi -# push changes git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' -# Configure the remote with the token +# Configure the remote with the token for pushes. git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" -TARGET_TAG_NAME="v${TARGET_VERSION}" +# Prerelease path: tag, push, dispatch pypi.yml. (Direct dispatch because +# GITHUB_TOKEN pushes don't fire downstream push: triggers; pypi.yml gates +# the upload on PUBLISH_PRERELEASES.) +if [ "${IS_PRERELEASE}" = "1" ]; then + git tag "${TARGET_TAG_NAME}" + git push origin "${TARGET_TAG_NAME}" + gh workflow run pypi.yml --ref "${TARGET_TAG_NAME}" + echo "Tagged prerelease ${TARGET_TAG_NAME}" + exit 0 +fi -# Commit and push version bump first so the tag has the right base -git add pyproject.toml uv.lock -COMMIT_MSG="chore: bump version to ${TARGET_VERSION} [skip ci]" -git commit -m "${COMMIT_MSG}" -git push origin main +# Final path. gh release create both tags and creates the Release object +# with notes generated against the previous Release. +gh release create "${TARGET_TAG_NAME}" \ + --target "${RELEASE_BRANCH}" \ + --generate-notes -# create GitHub release (incl. Git tag) with GitHub-native generated notes -gh release create "${TARGET_TAG_NAME}" --generate-notes +# Dispatch follow-on workflows directly (GITHUB_TOKEN-authored release/tag +# events don't auto-trigger). +gh workflow run pypi.yml --ref "${TARGET_TAG_NAME}" +gh workflow run docs-publish.yml --field "release_tag=${TARGET_TAG_NAME}" -# pull the generated notes back locally to update the changelog +# Changelog sync PR is release-branch → main; skip it when publishing from +# main itself. +if [ "${RELEASE_BRANCH}" = "main" ]; then + echo "Published ${TARGET_TAG_NAME} from main — skipping changelog sync PR" + exit 0 +fi + +# Pull the generated notes back locally to update the changelog. REL_NOTES=$(mktemp) gh release view "${TARGET_TAG_NAME}" --json body -q ".body" >> "${REL_NOTES}" -# update changelog +# Build the updated changelog. TMP_CHGLOG=$(mktemp) RELEASE_URL="$(gh repo view --json url -q ".url")/releases/tag/${TARGET_TAG_NAME}" -printf "## [${TARGET_TAG_NAME}](${RELEASE_URL}) - $(date -Idate)\n\n" >> "${TMP_CHGLOG}" +printf "## [%s](%s) - %s\n\n" "${TARGET_TAG_NAME}" "${RELEASE_URL}" "$(date -Idate)" >> "${TMP_CHGLOG}" cat "${REL_NOTES}" >> "${TMP_CHGLOG}" if [ -f "${CHGLOG_FILE}" ]; then printf "\n" | cat - "${CHGLOG_FILE}" >> "${TMP_CHGLOG}" fi mv "${TMP_CHGLOG}" "${CHGLOG_FILE}" +# Commit the changelog update to the release branch and push it. git add "${CHGLOG_FILE}" git commit -m "docs: update changelog for ${TARGET_TAG_NAME} [skip ci]" -git push origin main \ No newline at end of file +git push origin "${RELEASE_BRANCH}" + +# Open a PR against main syncing just the changelog delta. Main is never +# pushed to directly from this script; branch protection applies normally. +SYNC_BRANCH="chore/changelog-sync-${TARGET_VERSION}" +git fetch origin main +git checkout -B "${SYNC_BRANCH}" origin/main +# Pick just the changelog change from the commit we just made on the release branch. +git checkout "${RELEASE_BRANCH}" -- "${CHGLOG_FILE}" +git add "${CHGLOG_FILE}" +git commit -m "docs: sync changelog for ${TARGET_TAG_NAME}" +git push origin "${SYNC_BRANCH}" + +gh pr create \ + --base main \ + --head "${SYNC_BRANCH}" \ + --title "docs: sync changelog for ${TARGET_TAG_NAME}" \ + --body "Automated changelog sync from \`${RELEASE_BRANCH}\` after publishing [${TARGET_TAG_NAME}](${RELEASE_URL}). + +This PR brings the release-branch CHANGELOG entry back to main so the project root CHANGELOG remains the canonical history across all branches." diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 4dde1df71..000000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: "Run CD" - -on: - workflow_dispatch: - inputs: - force_release: - description: 'Force release even if previous checks fail.' - type: boolean - required: false - default: false - -permissions: {} - -env: - UV_FROZEN: "1" - CICD: 1 - -jobs: - code-checks: - permissions: - contents: read - uses: ./.github/workflows/quality.yml - # with: - # push_coverage: false - pre-release-check: - permissions: - contents: read - runs-on: ubuntu-latest - outputs: - TARGET_TAG_V: ${{ steps.version_check.outputs.TRGT_VERSION }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 0 # for fetching tags, required for semantic-release - persist-credentials: false - - name: Install uv and set the python version - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - with: - enable-cache: true - - name: Install dependencies - run: uv sync --only-dev - - name: Check version of potential release - id: version_check - run: | - TRGT_VERSION=$(uv run --no-sync semantic-release print-version) - echo "TRGT_VERSION=${TRGT_VERSION}" >> "$GITHUB_OUTPUT" - echo "${TRGT_VERSION}" - - name: Check notes of potential release - run: uv run --no-sync semantic-release changelog --unreleased - release: - permissions: - contents: write - needs: [code-checks, pre-release-check] - # Run this job only if the `TARGET_TAG_V` is set AND (the previous jobs - # were successful OR we are forcing the release). - if: >- - ${{ needs.pre-release-check.outputs.TARGET_TAG_V != '' && - ( success() || inputs.force_release ) }} - environment: auto-release - runs-on: ubuntu-latest - concurrency: release - steps: - - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1 - id: app-token - with: - app-id: ${{ vars.CI_APP_ID }} - private-key: ${{ secrets.CI_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - token: ${{ steps.app-token.outputs.token }} - fetch-depth: 0 # for fetching tags, required for semantic-release - persist-credentials: false - - name: Install uv and set the python version - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 - with: - enable-cache: true - - name: Install dependencies - run: uv sync --only-dev - - name: Run release script - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - TARGET_VERSION: ${{ needs.pre-release-check.outputs.TARGET_TAG_V }} - CHGLOG_FILE: CHANGELOG.md - GITHUB_REPOSITORY: ${{ github.repository }} - run: ./.github/scripts/release.sh - shell: bash diff --git a/.github/workflows/cherry-pick-to-release.yml b/.github/workflows/cherry-pick-to-release.yml new file mode 100644 index 000000000..9ff50cf23 --- /dev/null +++ b/.github/workflows/cherry-pick-to-release.yml @@ -0,0 +1,77 @@ +name: "Cherry-pick to release branch" + +# Cherry-picks one or more commits from main onto a release branch via direct +# push. `github-actions[bot]` needs bypass rights on the release/** ruleset +# to push cherry-picked commits directly. +# +# Pushes authored by GITHUB_TOKEN don't fire downstream `push:` triggers +# (GitHub anti-loop rule), so CI on the release branch won't auto-run after +# a cherry-pick. The final step explicitly dispatches ci.yml against the +# target branch to work around this. +# +# On conflict the workflow fails with a clear playbook for local resolution. +# See RELEASE.md for the full patch-release flow. + +on: + workflow_dispatch: + inputs: + target_branch: + description: 'Release branch to cherry-pick onto (e.g. release/v0.6)' + type: string + required: true + shas: + description: 'Space- or comma-separated list of commit SHAs from main' + type: string + required: true + +permissions: {} + +env: + UV_FROZEN: "1" + CICD: 1 + +jobs: + cherry-pick: + environment: auto-release + permissions: + contents: write # push cherry-picks to release branch + actions: write # dispatch ci.yml after push + runs-on: ubuntu-latest + steps: + - name: Validate target branch + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + run: | + if ! [[ "${TARGET_BRANCH}" =~ ^release/v ]]; then + echo "error: target_branch ${TARGET_BRANCH} must match release/v*" + exit 2 + fi + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ inputs.target_branch }} + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + persist-credentials: true + - name: Configure git user + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + - name: Cherry-pick and push + env: + AUTO_PUSH: "1" + INPUT_SHAS: ${{ inputs.shas }} + TARGET_BRANCH: ${{ inputs.target_branch }} + run: | + # Split commas or whitespace into an array of SHAs. + IFS=' ,' read -ra SHAS <<< "${INPUT_SHAS}" + if [ "${#SHAS[@]}" -eq 0 ]; then + echo "error: no SHAs provided" + exit 2 + fi + ./.github/scripts/cherry_pick_to_release.sh "${TARGET_BRANCH}" "${SHAS[@]}" + shell: bash + - name: Trigger CI on release branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TARGET_BRANCH: ${{ inputs.target_branch }} + run: gh workflow run ci.yml --ref "${TARGET_BRANCH}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20d99d9d8..e5ffaca25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,16 @@ on: pull_request: types: [opened, reopened, synchronize] merge_group: + push: + branches: ['release/**'] + # Dispatchable by cherry-pick-to-release.yml to run CI on direct-pushed + # cherry-picks. + workflow_dispatch: + inputs: + ref: + description: 'Branch or ref to run CI against' + type: string + required: false permissions: {} diff --git a/.github/workflows/cut-release-branch.yml b/.github/workflows/cut-release-branch.yml new file mode 100644 index 000000000..6d236c646 --- /dev/null +++ b/.github/workflows/cut-release-branch.yml @@ -0,0 +1,57 @@ +name: "Cut release branch" + +# Creates a release/vX.Y branch from main and bumps main to the next minor's +# .dev0. See RELEASE.md for the full workflow. +# +# Must be dispatched from the GitHub Actions UI. `github-actions[bot]` needs +# bypass rights on main's branch-protection ruleset to push the main-side +# dev bump. + +on: + workflow_dispatch: + inputs: + confirm_minor: + description: >- + Optional safety check: if set, must match the minor derived from + pyproject.toml on main (e.g. "0.6"). Leave blank to trust pyproject. + type: string + required: false + default: "" + +permissions: {} + +env: + UV_FROZEN: "1" + CICD: 1 + +jobs: + cut: + environment: auto-release + permissions: + contents: write # push main + new release branch + pull-requests: write # release.sh may open changelog sync PR if rc0 is published (flag on) + actions: write # release.sh dispatches pypi.yml after tagging rc0 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + persist-credentials: true + - name: Install uv and set the python version + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --only-dev + - name: Cut release branch + env: + CONFIRM_MINOR: ${{ inputs.confirm_minor }} + # Env vars for the inline rc0 release-publish step: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + CHGLOG_FILE: CHANGELOG.md + PUBLISH_PRERELEASES: ${{ vars.PUBLISH_PRERELEASES || 'false' }} + run: ./.github/scripts/cut_release_branch.sh + shell: bash diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml index ecba33cc0..457381e86 100644 --- a/.github/workflows/docs-publish.yml +++ b/.github/workflows/docs-publish.yml @@ -41,6 +41,12 @@ on: description: "Fail the build if validation checks fail" type: boolean default: false + release_tag: + description: >- + Set to a release tag (e.g. `v0.6.0`) to run the + deploy-on-release flow. Used by release.sh. + type: string + default: "" permissions: {} @@ -315,13 +321,15 @@ jobs: contents: write timeout-minutes: 10 - # Deploy on: push to main, release, force_publish via dispatch, - # or PRs labelled "docs-preview" (→ docs/preview branch). + # Deploy on: push to main, release event, force_publish or release_tag + # via dispatch, or PRs labelled "docs-preview" (→ docs/preview branch). + # The runtime latest-final-by-semver check below prevents older-branch + # patches from overwriting production docs. if: >- github.event_name == 'push' || github.event_name == 'release' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'docs-preview')) || - (github.event_name == 'workflow_dispatch' && inputs.force_publish) + (github.event_name == 'workflow_dispatch' && (inputs.force_publish || inputs.release_tag != '')) steps: - name: Download docs artifact @@ -330,10 +338,61 @@ jobs: name: docs-site path: docs-site/ + # For release events, confirm the published tag is the latest final by + # semver across all release branches. Without this, publishing v0.6.1 + # after v0.7.0 already exists would incorrectly downgrade docs/production + # to the older minor's docs. + - name: Check release is latest final + id: latest_check + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURRENT_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + REPO: ${{ github.repository }} + run: | + python3 - <<'PY' + import json, os, subprocess, sys + from packaging.version import Version, InvalidVersion + + current = os.environ["CURRENT_TAG"] + repo = os.environ["REPO"] + raw = subprocess.check_output( + ["gh", "api", f"repos/{repo}/releases", "--paginate", + "--jq", ".[] | select(.prerelease==false and .draft==false) | .tag_name"], + text=True, + ) + candidates = [] + for line in raw.splitlines(): + t = line.strip() + if not t: + continue + try: + candidates.append((Version(t.lstrip("v")), t)) + except InvalidVersion: + continue + + try: + current_version = Version(current.lstrip("v")) + except InvalidVersion: + print(f"error: current tag {current} is not a valid version") + sys.exit(1) + + # include the current release itself (the API may not include it + # yet if eventing races with the release event) + candidates.append((current_version, current)) + latest_version, latest_tag = max(candidates) + is_latest = (latest_tag == current or latest_version == current_version) + out = os.environ["GITHUB_OUTPUT"] + with open(out, "a") as fh: + fh.write(f"is_latest_final={'true' if is_latest else 'false'}\n") + fh.write(f"latest_tag={latest_tag}\n") + print(f"current={current} latest={latest_tag} is_latest={is_latest}") + PY + - name: Determine target branch id: target run: | - if [ "$EVENT_NAME" = "release" ]; then + if [ "$EVENT_NAME" = "release" ] || [ -n "${INPUTS_RELEASE_TAG}" ]; then echo "branch=docs/production" >> "$GITHUB_OUTPUT" elif [ "$EVENT_NAME" = "pull_request" ]; then echo "branch=docs/preview" >> "$GITHUB_OUTPUT" @@ -345,6 +404,7 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} INPUTS_TARGET_BRANCH: ${{ inputs.target_branch }} + INPUTS_RELEASE_TAG: ${{ inputs.release_tag }} - name: Add DO NOT EDIT warning run: | @@ -365,6 +425,13 @@ jobs: EOF - name: Deploy to ${{ steps.target.outputs.branch }} + # Skip production deploy when a release is not the latest final by + # semver (e.g. a patch on an older release branch). The latest_check + # step runs for both `release` events and workflow_dispatch with + # release_tag set; either path can request the skip. + if: >- + steps.latest_check.conclusion == 'skipped' || + steps.latest_check.outputs.is_latest_final == 'true' uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish-dev-from-main.yml b/.github/workflows/publish-dev-from-main.yml new file mode 100644 index 000000000..2ec1a8d96 --- /dev/null +++ b/.github/workflows/publish-dev-from-main.yml @@ -0,0 +1,81 @@ +name: "Publish dev release from main" + +# Iterates main's .devN counter and (optionally) publishes a development +# release. Intended for ad-hoc / case-by-case dev tags — e.g. when a +# contributor wants a tagged snapshot of main for debugging or external +# testing. Only tags and the .devN counter advance unless +# PUBLISH_PRERELEASES=true on the repo, in which case a GitHub release + +# PyPI upload happen too. +# +# See RELEASE.md for the full release-process documentation. + +on: + workflow_dispatch: {} + +permissions: {} + +env: + UV_FROZEN: "1" + CICD: 1 + +jobs: + guard: + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Require main + env: + REF_NAME: ${{ github.ref_name }} + run: | + if [ "${REF_NAME}" != "main" ]; then + echo "error: publish-dev-from-main must run against main; got ${REF_NAME}" + exit 2 + fi + publish: + environment: auto-release + permissions: + contents: write # push tag + bump commit to main + actions: write # release.sh dispatches pypi.yml after tagging + needs: [guard] + runs-on: ubuntu-latest + concurrency: release + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: main + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + persist-credentials: false + - name: Install uv and set the python version + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --only-dev + - name: Configure git user + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + # Publish-then-increment: first tag/release main's current .devN, then + # advance pyproject to .dev(N+1) so the next dispatch is set up. This + # makes "dev release main as it is right now" match the pyproject state + # the operator sees when inspecting the branch. + - name: Publish current .devN + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BRANCH: main + ALLOW_MAIN_RELEASE: "1" + CHGLOG_FILE: CHANGELOG.md + GITHUB_REPOSITORY: ${{ github.repository }} + PUBLISH_PRERELEASES: ${{ vars.PUBLISH_PRERELEASES || 'false' }} + run: ./.github/scripts/release.sh + shell: bash + - name: Advance .devN counter on main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + uv run --no-sync python .github/scripts/bump_version.py --mode dev + git push origin main diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 000000000..b0a153dc0 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,145 @@ +name: "Publish release" + +# Publishes a release from a release/v* branch. Operator picks a bump_type +# to advance the version (rc, final, patch-rc, patch-final) or re-publish +# whatever is currently in pyproject.toml (none). See RELEASE.md. + +on: + workflow_dispatch: + inputs: + bump_type: + description: >- + How to advance the version before publishing. Use `rc` throughout a + minor cycle, `patch-rc` throughout a patch cycle, `final` / + `patch-final` to promote an rc, or `none` to republish the + pyproject version as-is. + type: choice + options: + - rc + - final + - patch-rc + - patch-final + - none + required: true + default: rc + force_release: + description: 'Force release even if previous checks fail.' + type: boolean + required: false + default: false + allow_main_release: + description: 'Override the main-branch guard. Leave unchecked.' + type: boolean + required: false + default: false + +permissions: {} + +env: + UV_FROZEN: "1" + CICD: 1 + +jobs: + guard: + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Refuse to release from main + env: + REF_NAME: ${{ github.ref_name }} + ALLOW: ${{ inputs.allow_main_release }} + run: | + if [ "${REF_NAME}" = "main" ] && [ "${ALLOW}" != "true" ]; then + echo "error: refusing to release from main." + echo "Dispatch this workflow against a release/v* branch instead." + echo "See RELEASE.md for the release-branch workflow." + exit 2 + fi + code-checks: + permissions: + contents: read + uses: ./.github/workflows/quality.yml + pre-release-check: + permissions: + contents: read + needs: [guard] + runs-on: ubuntu-latest + outputs: + TARGET_TAG_V: ${{ steps.version_check.outputs.TRGT_VERSION }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install uv and set the python version + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --only-dev + - name: Compute target version + id: version_check + env: + BUMP_TYPE: ${{ inputs.bump_type }} + run: | + if [ "${BUMP_TYPE}" = "none" ]; then + TRGT_VERSION=$(uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version) + else + TRGT_VERSION=$(uv run --no-sync python .github/scripts/bump_version.py \ + --mode "${BUMP_TYPE}" --dry-run) + fi + echo "TRGT_VERSION=${TRGT_VERSION}" >> "$GITHUB_OUTPUT" + echo "Target version: ${TRGT_VERSION}" + release: + permissions: + contents: write # push tags, branches, commits + pull-requests: write # release.sh opens changelog sync PR for finals + actions: write # release.sh dispatches pypi.yml and docs-publish.yml + needs: [guard, code-checks, pre-release-check] + # Run only if a target version was computed AND (previous jobs succeeded + # OR the operator set force_release). + if: >- + ${{ needs.pre-release-check.outputs.TARGET_TAG_V != '' && + ( success() || inputs.force_release ) }} + environment: auto-release + runs-on: ubuntu-latest + concurrency: release + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + persist-credentials: false + - name: Install uv and set the python version + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + - name: Install dependencies + run: uv sync --only-dev + - name: Configure git user + run: | + git config --global user.name 'github-actions[bot]' + git config --global user.email 'github-actions[bot]@users.noreply.github.com' + - name: Bump version on release branch + if: inputs.bump_type != 'none' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + BUMP_TYPE: ${{ inputs.bump_type }} + REF_NAME: ${{ github.ref_name }} + run: | + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" + uv run --no-sync python .github/scripts/bump_version.py --mode "${BUMP_TYPE}" + git push origin "${REF_NAME}" + - name: Run release script + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_BRANCH: ${{ github.ref_name }} + ALLOW_MAIN_RELEASE: ${{ inputs.allow_main_release && '1' || '0' }} + CHGLOG_FILE: CHANGELOG.md + GITHUB_REPOSITORY: ${{ github.repository }} + PUBLISH_PRERELEASES: ${{ vars.PUBLISH_PRERELEASES || 'false' }} + run: ./.github/scripts/release.sh + shell: bash diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 96b5d0faf..edce589d3 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -1,8 +1,13 @@ name: "Build and publish package" -# Test comment for a build + +# Fires on v* tag push, or via explicit dispatch (release.sh uses this when +# GITHUB_TOKEN-authored pushes can't auto-trigger). Prereleases are gated on +# the PUBLISH_PRERELEASES repo variable; finals always publish. + on: - release: - types: [published] + push: + tags: ['v*'] + workflow_dispatch: env: UV_FROZEN: "1" @@ -22,19 +27,39 @@ jobs: permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: + - name: Decide whether to publish + id: gate + env: + REF_NAME: ${{ github.ref_name }} + PUBLISH_PRERELEASES: ${{ vars.PUBLISH_PRERELEASES || 'false' }} + run: | + VERSION="${REF_NAME#v}" + if [[ "${VERSION}" == *rc* ]] || [[ "${VERSION}" == *.dev* ]]; then + if [ "${PUBLISH_PRERELEASES}" != "true" ]; then + echo "Prerelease ${REF_NAME} with PUBLISH_PRERELEASES=false — skipping PyPI upload" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "skip=false" >> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + if: steps.gate.outputs.skip != 'true' with: persist-credentials: false - name: Install uv and set the python version + if: steps.gate.outputs.skip != 'true' uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 with: python-version: ${{ matrix.python-version }} enable-cache: false - name: Install dependencies + if: steps.gate.outputs.skip != 'true' run: uv sync --all-extras - name: Build package + if: steps.gate.outputs.skip != 'true' run: uv build - name: Publish distribution 📦 to PyPI + if: steps.gate.outputs.skip != 'true' uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 with: attestations: true diff --git a/RELEASE.md b/RELEASE.md index baeb246ac..297f01226 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,66 +1,244 @@ # RELEASE.md ## Overview -This document describes the release process for the project. It outlines how release candidates are prepared, how testing is performed, how the final package is published, and what conventions the project uses for versioning and communication. The goal is to ensure reliability, transparency, and predictability for users and contributors. -## Release Cadence -Releases are prepared weekly, with work occurring roughly on the following schedule: +Mellea uses a release-branch workflow. Every minor version has a long-lived +`release/vX.Y` branch that carries release candidates, the final minor release, +and any subsequent patch releases. `main` carries `.dev`-versioned work for the +next minor. + +This gives each release a frozen codebase without requiring cherry-picks back +into `main`, and keeps CD resilient to concurrent merges on `main`. -- **Mon–Tue:** Identify and finalize high‑priority pull requests for inclusion. -- **Wed:** Merge selected pull requests, lock down changes, and cut the release. +## Release Cadence -This schedule may shift depending on team availability, urgency of fixes, and the stability of the codebase. +Minor releases target a roughly 4-week cadence. Patch releases happen as +needed. ## Versioning -This project follows **[Semantic Versioning (semver)](https://semver.org)**. Breaking changes must be clearly documented and highlighted in release notes. -To see the current, **prospective** version that will be published by the next release action, you can run: -``` -uv run --no-sync semantic-release print-version +Versions follow **[PEP 440](https://peps.python.org/pep-0440/)** (which is +compatible with SemVer for final releases). + +| Phase | Branch | Version example | Tag | +|-------|--------|-----------------|-----| +| Dev on main | `main` | `0.6.0.dev0` | (untagged) | +| Release branch cut | `release/v0.6` | `0.6.0rc0` | `v0.6.0rc0` | +| Further RCs | `release/v0.6` | `0.6.0rc1`, `rc2`, … | `v0.6.0rcN` | +| Final minor | `release/v0.6` | `0.6.0` | `v0.6.0` | +| Patch RC | `release/v0.6` | `0.6.1rc0` | `v0.6.1rc0` | +| Patch final | `release/v0.6` | `0.6.1` | `v0.6.1` | +| Next minor dev on main | `main` | `0.7.0.dev0` | (untagged) | + +Invariants: + +- `main` always carries `X.Y.0.devN`. `main` is tagged only when a dev + publication runs (`publish-dev-from-main` workflow); never during routine + commits. +- Release branches always carry `X.Y.Zrc?` or `X.Y.Z`. +- Prereleases (`rcN`, `.devN`) always receive a git tag. PyPI upload is + governed by the `PUBLISH_PRERELEASES` repo variable (see below); + prereleases never produce a GitHub Release. + +## The `PUBLISH_PRERELEASES` flag + +Repo variable `PUBLISH_PRERELEASES` (default `false`) governs PyPI upload +for prereleases. Prereleases never produce a GitHub Release; the flag only +gates PyPI. + +| `PUBLISH_PRERELEASES` | rc / dev | Finals | +|-----------------------|----------|--------| +| `false` (default) | tag only | tag + GitHub Release + PyPI + changelog entry + sync PR | +| `true` | tag + PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | + +Tags always push. Users can install any tagged prerelease via +`pip install git+https://github.com/generative-computing/mellea@v0.6.0rc1` +regardless of the flag. + +Finals always follow the full release flow regardless of the flag. + +To enable prerelease publishing on PyPI, a repo admin sets the variable to +`true` under **Settings → Secrets and variables → Actions → Variables**. +No code change needed. + +## Workflows + +| Workflow | Purpose | +|----------|---------| +| `cut-release-branch` | Cut `release/vX.Y` from `main`, publish `X.Y.0rc0`, bump `main` to next minor `.dev0` | +| `publish-release` | Publish a release (rc, final, patch-rc, patch-final, or retry a failed publish) | +| `cherry-pick-to-release` | Cherry-pick commits from `main` onto a release branch | +| `publish-dev-from-main` | Iterate main's `.devN` counter and publish a dev release | + +All four are `workflow_dispatch`-only and run from the GitHub Actions UI. + +Whether any given prerelease (`rc`, `dev`) produces a PyPI artifact depends +on the `PUBLISH_PRERELEASES` flag described above. + +## Cutting a minor release branch + +When `main` is ready to freeze for the next minor: + +1. Go to **Actions → Cut release branch → Run workflow**. +2. Optionally enter the expected minor (e.g. `0.6`) in `confirm_minor` as a + safety check. Leave blank to trust whatever is in `pyproject.toml` on + `main`. +3. Run. + +The workflow: + +- Verifies `pyproject.toml` on `main` matches `X.Y.0.devN`. +- Creates `release/vX.Y` with version set to `X.Y.0rc0`. +- Publishes `X.Y.0rc0` per the `PUBLISH_PRERELEASES` flag (tag-only by + default; tag + PyPI when enabled). +- Pushes `main` with version bumped to `X.(Y+1).0.dev0`. + +The `main` push requires `github-actions[bot]` to be listed as a bypass actor +in the `main` branch-protection ruleset (see **Branch protection** below). + +## Publishing a release candidate + +Once a release branch exists: + +1. Go to **Actions → Publish release → Run workflow**. +2. Select the release branch (e.g. `release/v0.6`) from the branch picker. +3. Choose `bump_type: rc`. +4. Run. + +The workflow: + +- Computes the next rc (e.g. `0.6.0rc0` → `0.6.0rc1`). +- Commits the bump to the release branch. +- Pushes tag `v{version}`. PyPI upload happens only when + `PUBLISH_PRERELEASES=true`. No GitHub Release object, no changelog entry, + no sync PR — those are reserved for finals. + +## Promoting an RC to a final minor + +When testing on an RC is complete: + +1. **Actions → Publish release → Run workflow** against the same release branch. +2. `bump_type: final`. +3. Run. + +This creates the `v0.6.0` GitHub Release (with auto-generated notes from +the previous final), uploads to PyPI, appends to `CHANGELOG.md` on the +release branch, opens a sync PR to `main` with the changelog delta, and +triggers the docs production deploy. + +## Patch releases + +Patches live on the original release branch. `main` is touched only when a +`patch-final` lands and opens its changelog sync PR. + +### 1. Cherry-pick fixes + +1. Identify the commit SHAs on `main` that need to go into the patch. +2. **Actions → Cherry-pick to release branch → Run workflow**. +3. `target_branch`: `release/v0.6`; `shas`: space- or comma-separated SHAs. +4. Run. + +The workflow topologically sorts the SHAs by their position in `git log main`, +cherry-picks with `git cherry-pick -x`, and pushes directly to the release +branch (`github-actions[bot]` needs bypass on `release/**`). It then +dispatches `ci.yml` explicitly against the release branch since +`GITHUB_TOKEN` pushes do not fire `push:` triggers on other workflows. + +If the workflow hits a conflict it fails with a resolution playbook. To +resolve: + +```bash +git fetch origin +git checkout release/v0.6 +git reset --hard origin/release/v0.6 +./.github/scripts/cherry_pick_to_release.sh release/v0.6 [ ...] +# Resolve conflicts: +git add +git cherry-pick --continue +git push origin release/v0.6 ``` -## Pre‑Release Coordination -### 1. Identify Candidate Pull Requests -Early in the week, maintainers review open pull requests and determine which ones should be included in the upcoming release. +Requires push access to `release/**` (or bypass). + +### 2. Publish a patch RC and final + +1. **Publish release** against `release/v0.6` with `bump_type: patch-rc`. Produces + e.g. `v0.6.1rc0`. +2. Test. +3. **Publish release** again with `bump_type: patch-rc` for additional rcs if needed. +4. **Publish release** with `bump_type: patch-final` to promote to `v0.6.1`. + +## Publishing a dev release from main + +Ad-hoc, case-by-case `.devN` bumps on `main`. Typical uses: a contributor +wants a tagged snapshot of main for debugging, an external tester needs a +specific point-in-time artifact, etc. Not intended for routine or scheduled +releases. + +1. **Actions → Publish dev release from main → Run workflow** (must dispatch + against `main`). +2. Run. + +The workflow (publish-then-increment): + +1. Publishes `main`'s **current** `.devN` per `PUBLISH_PRERELEASES` — tag-only + by default, full release flow if enabled. The tag points at the current + `main` HEAD. +2. Iterates pyproject on main: `X.Y.Z.devN → X.Y.Z.dev(N+1)`, commits, pushes. + +The invariant is that `main`'s pyproject always carries "the next version +that would be published." Inspecting main tells you what the next dispatch +will produce. + +With `PUBLISH_PRERELEASES=false` (default) the outcome is a tag like +`v0.7.0.dev3` pointing at main HEAD and nothing else. With the flag enabled +it additionally uploads to PyPI (installable via `pip install --pre mellea`). +Dev publishes never create a GitHub Release or touch `CHANGELOG.md`. + +## Rollback and retry + +`bump_type: none` re-runs CD against whatever version is currently in +`pyproject.toml`, skipping the version-bump step. Useful when a previous +run failed after the bump committed but before the publish completed. + +## Release branch retention + +**Release branches are never deleted.** GitHub Releases pin to specific +commits on each branch, so pruning a branch would orphan those references and +break `git checkout v0.4.2` semantics. Old `release/v0.3`, `release/v0.4`, etc. +stay around indefinitely. -### 2. Merge or Defer Selected Pull Requests -By mid‑week, all targeted PRs should be merged or explicitly deferred. No new PRs should be merged once the release process begins. +## Branch protection -## Release Process -### 1. Freeze `main` -Once the release process begins, no new PRs may be merged into `main`. +All four write-capable workflows authenticate via `secrets.GITHUB_TOKEN`. +Each declares the scopes it needs via an inline `permissions:` block. -### 2. Run the Full Test Suite -All tests must pass before proceeding. The suite includes unit tests, integration tests, and example workflows. Issues are classified into quick fixes or larger problems requiring release delay. This should be run against python versions 3.11, 3.12, and 3.13. +`github-actions[bot]` (the identity `GITHUB_TOKEN` acts as) needs to be +listed as a **bypass actor** on two rulesets: -### 3. Trigger Release Automation -A GitHub Actions workflow handles the CI pipeline, branch cutting, and publishing to PyPI. +- `main`: `cut-release-branch` pushes the `X.(Y+1).0.dev0` bump directly; + `publish-dev-from-main` pushes the `.dev(N+1)` advance commit directly. +- `release/**`: `publish-release` pushes the version-bump commit; `cherry-pick-to-release` + pushes cherry-picked commits directly. -## Post‑Release Steps -### 1. Announcement & Communication -When a release includes noteworthy updates—especially breaking API changes—maintainers should publish release notes and communicate updates publicly. +Recommended ruleset for `release/**`: -### 2. Documentation Synchronization -Documentation must be updated before the release is finalized. API Documentation is automatically generated. +- Require pull request review (bypassable by `github-actions[bot]`). +- Require status checks to pass (CI). +- No force-push, no deletion. -### 3. Transparency: Test Strategy & Results -Testing methodology and coverage information may be made public where appropriate. +Docs publishing (`docs-publish.yml`) deploys to `docs/production` only when +a published GitHub Release is the latest final by semver, so older-branch +patches don't overwrite production docs. -## Release Types -- **Fix releases** – bug fixes and stability improvements -- **Feature releases** – new capabilities, backward compatible -- **Breaking releases** – major API revisions +## Docs behavior by release type -## Supported Use Cases & Stability -The project is in **beta**, so users should expect rapid iteration and occasional breaking changes. +| Release type | docs/production | docs/staging | +|--------------|-----------------|--------------| +| RC (`v0.6.0rc0`) | unchanged | unchanged | +| Final minor (`v0.6.0`) | deployed | (main-push rebuilds as usual) | +| Patch on latest minor (`v0.6.1` after `v0.6.0`) | deployed | unchanged | +| Patch on older minor (`v0.5.1` after `v0.6.0`) | unchanged | unchanged | -## Future Improvements -Future enhancements may include: -- add package dependency tests - - ensure each piece of mellea can run with only its necessary mellea package (ie huggingface with mellea[hf]) -- release candidate branch - - the job supports a branch selector; we should make sure it works as expected - - this also means we need to keep track of which PRs need to be double merged (ie into both the release and main branches) -- improved PR coordination - - this becomes less necessary with a candidate release branch -- publishing a standardized test strategy +Versioned docs (per-minor URL prefixes and a version switcher) would supersede +the latest-final-by-semver gate; not in scope here. diff --git a/pyproject.toml b/pyproject.toml index 35823fb9c..c907d8bb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "pdm.backend" [project] name = "mellea" -version = "0.5.0" +version = "0.6.0.dev0" authors = [ { name = "Mellea Contributors", email = "melleaadmin@ibm.com" }, ] @@ -155,17 +155,12 @@ build = [ "pdm>=2.24.0", ] -release = [ - "python-semantic-release~=7.32", -] - # Convenience: includes all dev dependencies (used by default with uv run) dev = [ {include-group = "lint"}, {include-group = "test"}, {include-group = "typecheck"}, {include-group = "build"}, - {include-group = "release"}, "griffe>=2.0.0", "mdxify>=0.2.37", "mellea[server]", @@ -289,6 +284,7 @@ split-on-trailing-comma = false "test/**/*.py" = ["E402", "D"] "cli/**/*.py" = ["D"] "tooling/**/*.py" = ["D"] +".github/scripts/**/*.py" = ["D"] # ----------------------------- # MyPy - Type Checking @@ -408,23 +404,5 @@ exclude_lines = [ [tool.coverage.html] directory = "htmlcov" -# ----------------------------- -# Semantic Release - Versioning -# ----------------------------- - -[tool.semantic_release] -# for default values check: -# https://github.com/python-semantic-release/python-semantic-release/blob/v7.32.2/semantic_release/defaults.cfg - -version_source = "tag_only" -branch = "main" -major_on_zero = false - -# configure types which should trigger minor and patch version bumps respectively -# (note that they must be a subset of the configured allowed types): -parser_angular_allowed_types = "build,chore,ci,docs,feat,fix,perf,style,refactor,test,release" -parser_angular_minor_types = "release" -parser_angular_patch_types = "fix,perf,feat" - [tool.uv.sources] mellea = { workspace = true } diff --git a/test/scripts/test_bump_version.py b/test/scripts/test_bump_version.py new file mode 100644 index 000000000..b286c61f7 --- /dev/null +++ b/test/scripts/test_bump_version.py @@ -0,0 +1,89 @@ +"""Unit tests for `.github/scripts/bump_version.py`.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest +from packaging.version import Version + +REPO_ROOT = Path(__file__).resolve().parents[2] +SCRIPT = REPO_ROOT / ".github" / "scripts" / "bump_version.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("bump_version", SCRIPT) + assert spec and spec.loader + mod = importlib.util.module_from_spec(spec) + sys.modules["bump_version"] = mod + spec.loader.exec_module(mod) + return mod + + +bump_version = _load_module() + + +@pytest.mark.parametrize( + ("current", "mode", "expected"), + [ + ("0.6.0rc0", "rc", "0.6.0rc1"), + ("0.6.0rc5", "rc", "0.6.0rc6"), + ("0.6.0rc0", "final", "0.6.0"), + ("0.6.0rc2", "final", "0.6.0"), + ("0.6.0", "patch-rc", "0.6.1rc0"), + ("0.6.1rc0", "patch-rc", "0.6.1rc1"), + ("0.6.1rc3", "patch-rc", "0.6.1rc4"), + ("0.6.1rc0", "patch-final", "0.6.1"), + ("0.6.5rc2", "patch-final", "0.6.5"), + ("0.6.0.dev0", "dev", "0.6.0.dev1"), + ("0.7.0.dev3", "dev", "0.7.0.dev4"), + ], +) +def test_compute_next_happy_paths(current, mode, expected): + got = bump_version.compute_next(Version(current), mode) + assert str(got) == expected + + +@pytest.mark.parametrize( + ("current", "mode"), + [ + # rc requires current rc + ("0.6.0", "rc"), + # final requires current rc, not a final + ("0.6.0", "final"), + # final is for minor rcs (patch=0), not patch rcs + ("0.6.1rc0", "final"), + # patch-rc refuses minor rcs (patch=0) + ("0.6.0rc0", "patch-rc"), + # patch-final refuses minor rcs + ("0.6.0rc0", "patch-final"), + # patch-final requires current rc + ("0.6.1", "patch-final"), + # dev versions should never appear on release branches + ("0.6.0.dev0", "rc"), + ("0.6.0.dev0", "final"), + # dev mode requires a .dev release; refuses finals and rcs + ("0.6.0", "dev"), + ("0.6.0rc0", "dev"), + ], +) +def test_compute_next_rejects_disallowed(current, mode): + with pytest.raises(ValueError): + bump_version.compute_next(Version(current), mode) + + +def test_compute_next_unknown_mode(): + with pytest.raises(ValueError): + bump_version.compute_next(Version("0.6.0rc0"), "bogus") + + +def test_write_pyproject_roundtrip(tmp_path, monkeypatch): + fake = tmp_path / "pyproject.toml" + fake.write_text('[project]\nname = "x"\nversion = "0.6.0rc0"\ndependencies = []\n') + monkeypatch.setattr(bump_version, "PYPROJECT", fake) + bump_version.write_pyproject(Version("0.6.0rc1")) + assert 'version = "0.6.0rc1"' in fake.read_text() + # only the version line changed, everything else is preserved + assert 'name = "x"' in fake.read_text() diff --git a/uv.lock b/uv.lock index cfd231228..bf7e473cd 100644 --- a/uv.lock +++ b/uv.lock @@ -430,15 +430,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] -[[package]] -name = "backports-tarfile" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -725,19 +716,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] -[[package]] -name = "click-log" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, - { name = "click", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/32/32/228be4f971e4bd556c33d52a22682bfe318ffe57a1ddb7a546f347a90260/click-log-0.4.0.tar.gz", hash = "sha256:3970f8570ac54491237bcdb3d8ab5e3eef6c057df29f8c3d1151a51a9c23b975", size = 9985, upload-time = "2022-03-13T11:10:15.262Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/5a/4f025bc751087833686892e17e7564828e409c43b632878afeae554870cd/click_log-0.4.0-py2.py3-none-any.whl", hash = "sha256:a43e394b528d52112af599f2fc9e4b7cf3c15f94e53581f74fa6867e68c91756", size = 4273, upload-time = "2022-03-13T11:10:17.594Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -1312,24 +1290,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/93/0c7bc48ff471113dad488f5415c141cfb77e004e52f6ab5b7cb3f8fb0b3e/docling_parse-5.10.1-cp314-cp314-win_amd64.whl", hash = "sha256:72762914e05708b67d65b9307be97376a0e6d4cc014f7899887c51d68c722841", size = 11322428, upload-time = "2026-04-24T15:02:15.567Z" }, ] -[[package]] -name = "docutils" -version = "0.22.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, -] - -[[package]] -name = "dotty-dict" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/ab/88d67f02024700b48cd8232579ad1316aa9df2272c63049c27cc094229d6/dotty_dict-1.3.1.tar.gz", hash = "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15", size = 7699, upload-time = "2022-07-09T18:50:57.727Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" }, -] - [[package]] name = "elastic-transport" version = "8.17.1" @@ -1634,30 +1594,6 @@ http = [ { name = "aiohttp", version = "3.13.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" }, ] -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, -] - -[[package]] -name = "gitpython" -version = "3.1.48" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/c3/e4a9656f3cdb280f5dc65a68cc6fc46e79f897d27c1a36bbb4f0f47aaac5/gitpython-3.1.48.tar.gz", hash = "sha256:b7c49ff4a49946fce38ac84116efa311b15e7dad06dc3787fc9e206bf9ef75e1", size = 217288, upload-time = "2026-04-28T05:35:45.328Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/50/bb9703c364c00e7be67ccda03536f3d684766ce109d184c9d1f072d866ca/gitpython-3.1.48-py3-none-any.whl", hash = "sha256:737698b05889cca0f9aba7054d796620df2092c68926ee1470e5c7f5ac886680", size = 209800, upload-time = "2026-04-28T05:35:42.543Z" }, -] - [[package]] name = "googleapis-common-protos" version = "1.74.0" @@ -2093,15 +2029,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/a8/7a1659fa61a225ea23b8e5d6d376e3ec427c994116141e6a1617dc253e0a/installer-1.0.0-py3-none-any.whl", hash = "sha256:7b46327ded20d8544bfe2d8561618bbcd12d88e7e3645333af1ed141d8bc1bfe", size = 464228, upload-time = "2026-03-28T15:39:20.345Z" }, ] -[[package]] -name = "invoke" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/bd/b461d3424a24c80490313fd77feeb666ca4f6a28c7e72713e3d9095719b4/invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707", size = 304762, upload-time = "2025-10-11T00:36:35.172Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8", size = 160287, upload-time = "2025-10-11T00:36:33.703Z" }, -] - [[package]] name = "ipykernel" version = "7.2.0" @@ -2198,42 +2125,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] -[[package]] -name = "jaraco-classes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, -] - -[[package]] -name = "jaraco-context" -version = "6.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, -] - -[[package]] -name = "jaraco-functools" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, -] - [[package]] name = "jedi" version = "0.19.2" @@ -2246,15 +2137,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] -[[package]] -name = "jeepney" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -2703,24 +2585,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, ] -[[package]] -name = "keyring" -version = "25.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "jaraco-classes" }, - { name = "jaraco-context" }, - { name = "jaraco-functools" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", marker = "sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, -] - [[package]] name = "langchain-classic" version = "1.0.4" @@ -3334,7 +3198,7 @@ wheels = [ [[package]] name = "mellea" -version = "0.5.0" +version = "0.6.0.dev0" source = { editable = "." } dependencies = [ { name = "jinja2" }, @@ -3492,7 +3356,6 @@ dev = [ { name = "pytest-recording" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, - { name = "python-semantic-release" }, { name = "ruff" }, { name = "sentencepiece" }, { name = "types-requests" }, @@ -3509,9 +3372,6 @@ notebook = [ { name = "ipython" }, { name = "jupyter" }, ] -release = [ - { name = "python-semantic-release" }, -] test = [ { name = "ddgs" }, { name = "jsonschema", version = "4.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, @@ -3612,7 +3472,6 @@ dev = [ { name = "pytest-recording" }, { name = "pytest-timeout" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "python-semantic-release", specifier = "~=7.32" }, { name = "ruff", specifier = ">=0.15.5" }, { name = "sentencepiece", specifier = "==0.2.1" }, { name = "types-requests" }, @@ -3629,7 +3488,6 @@ notebook = [ { name = "ipython", specifier = ">=8.36.0" }, { name = "jupyter", specifier = ">=1.1.1" }, ] -release = [{ name = "python-semantic-release", specifier = "~=7.32" }] test = [ { name = "ddgs", specifier = ">=9.0.0" }, { name = "jsonschema" }, @@ -3669,15 +3527,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, ] -[[package]] -name = "more-itertools" -version = "11.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/f7/139d22fef48ac78127d18e01d80cf1be40236ae489769d17f35c3d425293/more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804", size = 144659, upload-time = "2026-04-09T15:01:33.297Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/98/6af411189d9413534c3eb691182bff1f5c6d44ed2f93f2edfe52a1bbceb8/more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4", size = 71939, upload-time = "2026-04-09T15:01:32.21Z" }, -] - [[package]] name = "mpire" version = "2.10.2" @@ -4045,40 +3894,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] -[[package]] -name = "nh3" -version = "0.3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9c/5f/1d19bdc7d27238e37f3672cdc02cb77c56a4a86d140cd4f4f23c90df6e16/nh3-0.3.5.tar.gz", hash = "sha256:45855e14ff056064fec77133bfcf7cd691838168e5e17bbef075394954dc9dc8", size = 20743, upload-time = "2026-04-25T10:44:16.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/b0/8587ac42a9627ab88e7e221601f1dfccbf4db80b2a29222ea63266dc9abc/nh3-0.3.5-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:23a312224875f72cd16bde417f49071451877e29ef646a60e50fcb69407cc18a", size = 1420126, upload-time = "2026-04-25T10:43:39.834Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/1dbc4d0c43f12e8c1784ede17eaee6f061d4fbe5505757c65c49b2ceab95/nh3-0.3.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:387abd011e81959d5a35151a11350a0795c6edeb53ebfa02d2e882dc01299263", size = 793943, upload-time = "2026-04-25T10:43:41.363Z" }, - { url = "https://files.pythonhosted.org/packages/47/9f/d6758d7a14ee964bf439cc35ae4fa24a763a93399c8ef6f22bd11d532d29/nh3-0.3.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48f45e3e914be93a596431aa143dedf1582557bf41a58153c296048d6e3798c9", size = 841150, upload-time = "2026-04-25T10:43:43.007Z" }, - { url = "https://files.pythonhosted.org/packages/b6/36/d5d1ae8374612c98f390e1ea7c610fa6c9716259a03bbf4d15b269f40073/nh3-0.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0a09f51806fd51b4fedbf9ea2b61fef388f19aef0d62fe51199d41648be14588", size = 1008415, upload-time = "2026-04-25T10:43:44.324Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8f/d13a9c3fd2d9c131a2a281737380e9379eb0f8c33fea24c2b923aaafbb15/nh3-0.3.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c357f1d042c67f135a5e6babb2b0e3b9d9224ff4a3543240f597767b01384ffd", size = 1092706, upload-time = "2026-04-25T10:43:45.653Z" }, - { url = "https://files.pythonhosted.org/packages/bb/57/2f3add7f8680fcc896afa6a675cb2bab09982853ee8af40bad621f6b61c4/nh3-0.3.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:38748140bf76383ab7ce2dce0ad4cb663855d8fbc9098f7f3483673d09616a17", size = 1048346, upload-time = "2026-04-25T10:43:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/c1/c3/2f9e4ffa82863074d1361bfe949bc46393d91b3411579dfbbd090b24cac5/nh3-0.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:84bdeb082544fbcb77a12c034dd77d7da0556fdc0727b787eb6214b958c15e29", size = 1029038, upload-time = "2026-04-25T10:43:48.569Z" }, - { url = "https://files.pythonhosted.org/packages/e8/10/2804deb3f3315184c9cae41702e293c87524b5a21f766b07d7fe3ffbcfbb/nh3-0.3.5-cp314-cp314t-win32.whl", hash = "sha256:c3aae321f67ae66cff2a627115f106a377d4475d10b0e13d97959a13486b9a88", size = 603263, upload-time = "2026-04-25T10:43:49.851Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a2/f6685248b49f7548fc9a8c335ab3a52f68610b72e8a61576447151e4e2e6/nh3-0.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c88605d8d468f7fc1b31e06129bc91d6c96f6c621776c9b504a0da9beac9df5f", size = 616866, upload-time = "2026-04-25T10:43:51.005Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/d8c9018635d4acfefde6b68470daa510eed715a350cbaa2f928ba0609f81/nh3-0.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:72c5bdedec27fa33de6a5326346ea8aa3fe54f6ac294d54c4b204fb66a9f1e79", size = 602566, upload-time = "2026-04-25T10:43:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/85/30/d162e99746a2fb1d98bb0ef23af3e201b156cf09f7de867c7390c8fe1c06/nh3-0.3.5-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3bb854485c9b33e5bb143ff3e49e577073bc6bc320f0ff8fc316dd89c0d3c101", size = 1442393, upload-time = "2026-04-25T10:43:53.556Z" }, - { url = "https://files.pythonhosted.org/packages/25/8c/072120d506978ab053e1732d0efa7c86cb478fee0ee098fda0ac0d31cb34/nh3-0.3.5-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50d401ab2d8e86d59e2126e3ab2a2f45840c405842b626d9a51624b3a33b6878", size = 837722, upload-time = "2026-04-25T10:43:55.073Z" }, - { url = "https://files.pythonhosted.org/packages/52/86/d4e06e28c5ad1c4b065f89737d02631bd49f1660b6ebcf17a87ffcd201da/nh3-0.3.5-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acfd354e61accbe4c74f8017c6e397a776916dfe47c48643cf7fd84ade826f93", size = 822872, upload-time = "2026-04-25T10:43:56.581Z" }, - { url = "https://files.pythonhosted.org/packages/0a/62/50659255213f241ec5797ae7427464c969397373e83b3659372b341ae869/nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:52d877980d7ca01dc3baf3936bf844828bc6f332962227a684ed79c18cce14c3", size = 1100031, upload-time = "2026-04-25T10:43:58.098Z" }, - { url = "https://files.pythonhosted.org/packages/00/7a/a12ae77593b2fcf3be25df7bc1c01967d0de448bdb4b6c7ec80fe4f5a74f/nh3-0.3.5-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:207c01801d3e9bb8ec08f08689346bdd30ce15b8bf60013a925d08b5388962a4", size = 1057669, upload-time = "2026-04-25T10:43:59.328Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/5647dc04c0233192a3956fc91708822b21403a06508cacf78083c68e7bf0/nh3-0.3.5-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea232933394d1d58bf7c4bb348dc4660eae6604e1ae81cd2ba6d9ed80d390f3b", size = 914795, upload-time = "2026-04-25T10:44:00.52Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0e/bf298920729f216adcb002acf7ea01b90842603d2e4e2ce9b900d9ee8fab/nh3-0.3.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe3a787dc76b50de6bee54ef242f26c41dfe47654428e3e94f0fae5bb6dd2cc1", size = 806976, upload-time = "2026-04-25T10:44:01.743Z" }, - { url = "https://files.pythonhosted.org/packages/85/01/26761e1dc2b848e65a62c19e5d39ad446283287cd4afddc89f364ab86bc9/nh3-0.3.5-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:488928988caad25ba14b1eb5bc74e25e21f3b5e40341d956f3ce4a8bc19460dc", size = 834904, upload-time = "2026-04-25T10:44:03.454Z" }, - { url = "https://files.pythonhosted.org/packages/33/53/0766113e679540ac1edc1b82b1295aecd321eeb75d6fead70109a838b6ee/nh3-0.3.5-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c069570b06aa848457713ad7af4a9905691291548c4466a9ad78ee95808382b", size = 857159, upload-time = "2026-04-25T10:44:05.003Z" }, - { url = "https://files.pythonhosted.org/packages/58/36/734d353dfaf292fed574b8b3092f0ef79dc6404f3879f7faaa61a4701fad/nh3-0.3.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:eeedc90ed8c42c327e8e10e621ccfa314fc6cce35d5929f4297ff1cdb89667c4", size = 1018600, upload-time = "2026-04-25T10:44:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/6b/aa/d9c59c1b49669fcb7bababa55df82385f029ad5c2651f583c3a1141cfdd1/nh3-0.3.5-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:de8e8621853b6470fe928c684ee0d3f39ea8086cebafe4c416486488dea7b68d", size = 1103530, upload-time = "2026-04-25T10:44:07.68Z" }, - { url = "https://files.pythonhosted.org/packages/90/b0/cdd210bfb8d9d43fb02fc3c868336b9955934d8e15e66eb1d15a147b8af0/nh3-0.3.5-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:6ea58cc44d274c643b83547ca9654a0b1a817609b160601356f76a2b744c49ad", size = 1061754, upload-time = "2026-04-25T10:44:09.362Z" }, - { url = "https://files.pythonhosted.org/packages/ce/cb/7a39e72e668c8445bdd95e494b3e21cfdddc68329be8ea3522c8befb46c4/nh3-0.3.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e49c9b564e6bcb03ecd2f057213df9a0de15a95812ac9db9600b590db23d3ae9", size = 1040938, upload-time = "2026-04-25T10:44:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/4c/fc2f9ed208a3801a319f59b5fea03cdc20cf3bd8af14be930d3a8de01224/nh3-0.3.5-cp38-abi3-win32.whl", hash = "sha256:559e4c73b689e9a7aa97ac9760b1bc488038d7c1a575aa4ab5a0e19ee9630c0f", size = 611445, upload-time = "2026-04-25T10:44:12.317Z" }, - { url = "https://files.pythonhosted.org/packages/db/1a/e4c9b5e2ae13e6092c9ec16d8ca30646cb01fcdea245f36c5b08fd21fbd5/nh3-0.3.5-cp38-abi3-win_amd64.whl", hash = "sha256:45e6a65dc88a300a2e3502cb9c8e6d1d6b831d6fba7470643333609c6aab1f30", size = 626502, upload-time = "2026-04-25T10:44:13.682Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/19cd0671d1ba2762fb388fc149697d20d0568ccfeef833b11280a619e526/nh3-0.3.5-cp38-abi3-win_arm64.whl", hash = "sha256:8f85285700a18e9f3fc5bff41fe573fa84f81542ef13b48a89f9fecca0474d3b", size = 611069, upload-time = "2026-04-25T10:44:14.934Z" }, -] - [[package]] name = "nltk" version = "3.9.4" @@ -4992,15 +4807,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, ] -[[package]] -name = "pkginfo" -version = "1.12.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/03/e26bf3d6453b7fda5bd2b84029a426553bb373d6277ef6b5ac8863421f87/pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b", size = 451828, upload-time = "2025-02-19T15:27:37.188Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/3d/f4f2ba829efb54b6cd2d91349c7463316a9cc55a43fc980447416c88540f/pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343", size = 32717, upload-time = "2025-02-19T15:27:33.071Z" }, -] - [[package]] name = "platformdirs" version = "4.9.6" @@ -5976,19 +5782,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "python-gitlab" -version = "3.15.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, - { name = "requests-toolbelt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/53/248b87282df591d74ba3d38c3c3ced2b5087248c0ccfb6b3a947bb1034c3/python-gitlab-3.15.0.tar.gz", hash = "sha256:c9e65eb7612a9fbb8abf0339972eca7fd7a73d4da66c9b446ffe528930aff534", size = 273270, upload-time = "2023-06-09T09:51:31.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/51/3c7dd08272658e5490d0c0b6c94af15bd0c0649e7ad23c9ed0db1d276143/python_gitlab-3.15.0-py3-none-any.whl", hash = "sha256:8f8d1c0d387f642eb1ac7bf5e8e0cd8b3dd49c6f34170cee3c7deb7d384611f3", size = 135865, upload-time = "2023-06-09T09:51:29.996Z" }, -] - [[package]] name = "python-json-logger" version = "4.1.0" @@ -6022,30 +5815,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, ] -[[package]] -name = "python-semantic-release" -version = "7.34.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, - { name = "click", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" }, - { name = "click-log" }, - { name = "dotty-dict" }, - { name = "gitpython" }, - { name = "invoke" }, - { name = "packaging" }, - { name = "python-gitlab" }, - { name = "requests" }, - { name = "semver" }, - { name = "tomlkit" }, - { name = "twine" }, - { name = "wheel" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/67/abf0ed527dafc5545b2ae264ec090b9849fac7775c319f0f6da95a50e9b7/python-semantic-release-7.34.6.tar.gz", hash = "sha256:e9b8fb788024ae9510a924136d573588415a16eeca31cc5240f2754a80a2e831", size = 41885, upload-time = "2023-06-17T14:12:17.089Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/53/d9b4c4a811a946489f89b62b02b01e9e456dc8c3bde154a18eac4f1dcbe4/python_semantic_release-7.34.6-py3-none-any.whl", hash = "sha256:7e3969ba4663d9b2087b02bf3ac140e202551377bf045c34e09bfe19753e19ab", size = 55637, upload-time = "2023-06-17T14:12:14.975Z" }, -] - [[package]] name = "pytz" version = "2026.1.post1" @@ -6074,15 +5843,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, ] -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, -] - [[package]] name = "pywinpty" version = "3.0.3" @@ -6237,20 +5997,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/4a/fa521d947f0fc7bb304bf11bec4cb66266bd81494588b4cb48dc01001719/rapidocr-3.8.1-py3-none-any.whl", hash = "sha256:650044b1fbce9e6bae5cae462dcf8be754cde11e2f23fc51f65dcc08deae2c46", size = 15080319, upload-time = "2026-04-11T07:13:22.56Z" }, ] -[[package]] -name = "readme-renderer" -version = "44.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "nh3" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056, upload-time = "2024-07-08T15:00:57.805Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310, upload-time = "2024-07-08T15:00:56.577Z" }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -6417,15 +6163,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, ] -[[package]] -name = "rfc3986" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026, upload-time = "2022-01-10T00:52:30.832Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326, upload-time = "2022-01-10T00:52:29.594Z" }, -] - [[package]] name = "rfc3986-validator" version = "0.1.1" @@ -6783,19 +6520,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] -[[package]] -name = "secretstorage" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, -] - [[package]] name = "semchunk" version = "3.2.5" @@ -6809,15 +6533,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/95/12d226ee4d207cb1f77a216baa7e1a8bae2639733c140abe8d0316d23a18/semchunk-3.2.5-py3-none-any.whl", hash = "sha256:fd09cc5f380bd010b8ca773bd81893f7eaf11d37dd8362a83d46cedaf5dae076", size = 13048, upload-time = "2025-10-28T02:12:36.724Z" }, ] -[[package]] -name = "semver" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/a9/b61190916030ee9af83de342e101f192bbb436c59be20a4cb0cdb7256ece/semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f", size = 45816, upload-time = "2020-10-20T20:16:54.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/70/b84f9944a03964a88031ef6ac219b6c91e8ba2f373362329d8770ef36f02/semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", size = 12901, upload-time = "2020-10-20T20:16:52.583Z" }, -] - [[package]] name = "send2trash" version = "2.1.0" @@ -6988,15 +6703,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "smmap" -version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, -] - [[package]] name = "smolagents" version = "1.24.0" @@ -7625,28 +7331,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] -[[package]] -name = "twine" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, - { name = "importlib-metadata", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' or python_full_version >= '3.14'" }, - { name = "keyring" }, - { name = "pkginfo" }, - { name = "readme-renderer" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "rfc3986" }, - { name = "tqdm" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/3e/ce331d7e215abdc16c53e65f8506bfccf4840ce191b709a37b8c83cc32c7/twine-3.8.0.tar.gz", hash = "sha256:8efa52658e0ae770686a13b675569328f1fba9837e5de1867bfe5f46a9aefe19", size = 214568, upload-time = "2022-02-02T18:50:23.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/74/ea7dfb86223695fd8efa256a24d1520729dde79a4e628ee6879f0f136d40/twine-3.8.0-py3-none-any.whl", hash = "sha256:d0550fca9dc19f3d5e8eadfce0c227294df0a2a951251a4385797c8a6198b7c8", size = 36057, upload-time = "2022-02-02T18:50:21.723Z" }, -] - [[package]] name = "typer" version = "0.21.2" @@ -7927,18 +7611,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] -[[package]] -name = "wheel" -version = "0.47.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, -] - [[package]] name = "widgetsnbextension" version = "4.0.15" From bded76cc0dc6c978f4669efe1472fd4945969b4c Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Thu, 14 May 2026 13:53:41 -0500 Subject: [PATCH 2/9] fix(release): address review feedback on release-branch workflow - cherry_pick_to_release.sh: enable pipefail so pipeline failures are not swallowed - release.sh: enable -u and pipefail; reuse existing changelog-sync branch on retry instead of force-recreating it - bump_version.py: override inherited UV_FROZEN=1 so uv lock can update the lockfile after a version bump - pypi.yml: read version from pyproject.toml so the prerelease gate works on manual workflow_dispatch (where github.ref_name is the branch, not the tag) Assisted-by: Claude Code Signed-off-by: Alex Bozarth --- .github/scripts/bump_version.py | 10 +++++++++- .github/scripts/cherry_pick_to_release.sh | 2 +- .github/scripts/release.sh | 12 ++++++++++-- .github/workflows/pypi.yml | 16 +++++++++------- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/scripts/bump_version.py b/.github/scripts/bump_version.py index 27f69bcbf..ed2b27067 100755 --- a/.github/scripts/bump_version.py +++ b/.github/scripts/bump_version.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse +import os import re import subprocess import sys @@ -204,7 +205,14 @@ def main() -> int: return 0 write_pyproject(next_version) - run(["uv", "lock", "--upgrade-package", "mellea"]) + # 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"}, + ) run(["git", "add", "pyproject.toml", "uv.lock"]) run(["git", "commit", "-m", f"release: bump version to {next_version} [skip ci]"]) diff --git a/.github/scripts/cherry_pick_to_release.sh b/.github/scripts/cherry_pick_to_release.sh index 227b867a3..28e0dd7ac 100755 --- a/.github/scripts/cherry_pick_to_release.sh +++ b/.github/scripts/cherry_pick_to_release.sh @@ -20,7 +20,7 @@ # 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 +set -euo pipefail if [ "$#" -lt 2 ]; then >&2 echo "usage: $0 [ ...]" diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index 44a62f2fd..a44abc641 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -17,7 +17,7 @@ # append to the changelog on the release branch, and open a sync PR to main # with the changelog delta. -set -e +set -euo pipefail set -x if [ -z "${RELEASE_BRANCH:-}" ]; then @@ -99,7 +99,15 @@ git push origin "${RELEASE_BRANCH}" # pushed to directly from this script; branch protection applies normally. SYNC_BRANCH="chore/changelog-sync-${TARGET_VERSION}" git fetch origin main -git checkout -B "${SYNC_BRANCH}" origin/main +# If the sync branch already exists on origin (e.g. a previous run got past +# the push but failed before opening the PR), reuse it so we don't silently +# discard prior commits. Otherwise branch fresh from origin/main. +if git rev-parse --verify "refs/remotes/origin/${SYNC_BRANCH}" >/dev/null 2>&1; then + git checkout "${SYNC_BRANCH}" + git reset --hard "origin/${SYNC_BRANCH}" +else + git checkout -b "${SYNC_BRANCH}" origin/main +fi # Pick just the changelog change from the commit we just made on the release branch. git checkout "${RELEASE_BRANCH}" -- "${CHGLOG_FILE}" git add "${CHGLOG_FILE}" diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index edce589d3..97b3a3a2c 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -27,25 +27,27 @@ jobs: permissions: id-token: write # IMPORTANT: mandatory for trusted publishing steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Decide whether to publish id: gate env: - REF_NAME: ${{ github.ref_name }} PUBLISH_PRERELEASES: ${{ vars.PUBLISH_PRERELEASES || 'false' }} + # Read version from pyproject.toml, not github.ref_name: on a manual + # workflow_dispatch ref_name is the branch (release/v0.6) and the + # prerelease pattern would not match, causing the gate to fail open. run: | - VERSION="${REF_NAME#v}" + VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + echo "Resolved version from pyproject.toml: ${VERSION}" if [[ "${VERSION}" == *rc* ]] || [[ "${VERSION}" == *.dev* ]]; then if [ "${PUBLISH_PRERELEASES}" != "true" ]; then - echo "Prerelease ${REF_NAME} with PUBLISH_PRERELEASES=false — skipping PyPI upload" + echo "Prerelease ${VERSION} with PUBLISH_PRERELEASES=false — skipping PyPI upload" echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi fi echo "skip=false" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - if: steps.gate.outputs.skip != 'true' - with: - persist-credentials: false - name: Install uv and set the python version if: steps.gate.outputs.skip != 'true' uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 From a209e23ceb9c8dea8fa6f885a09f0210fa31aed0 Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Mon, 18 May 2026 14:14:43 -0500 Subject: [PATCH 3/9] refactor(release): drop cherry-pick, gate prerelease publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the cherry-pick workflow and script in favor of manual followup PRs (release-branch fixes get a separate PR back to main). Gate prerelease tagging, GitHub Release creation, and PyPI upload behind the existing PUBLISH_PRERELEASES repo variable; with the default (false), the version bump commit is the only artifact for prereleases. Also addresses prior review on retry idempotency and CI plumbing. Cherry-pick removal: - Delete cherry_pick_to_release.sh and cherry-pick-to-release.yml - RELEASE.md: backports become a manual followup-PR flow - ci.yml: dispatch comment now points to release.sh sync-PR use Prerelease handling gated on PUBLISH_PRERELEASES: - Tag, prerelease GitHub Release, and PyPI upload only when the flag is true; otherwise the version-bump commit is the only artifact - Incremental notes: rc2 diffs against rc1 so testers see "what's new in this rc"; the cumulative view shows up on the final's Release START_TAG selection for --notes-start-tag: - Prereleases: most recent reachable tag (incremental) - Finals: previous final by version shape — git describe excluding rc/dev for patches, version-pattern lookup for minors (parallel release branches aren't reachable, empty for X.0.0 (gh's default fills in) Idempotency for bump_type=none retry path: - gh release create / git tag / git commit / sync PR creation all guarded against repeat runs; retry skips what's done, finishes what isn't Other CI plumbing fixes: - release.sh: explicit gh workflow run ci.yml after sync PR - docs-publish.yml: latest_check is continue-on-error and the deploy gate fails open on missing output - cut-release-branch.yml: concurrency: release group added Assisted-by: Claude Code EOF ) Signed-off-by: Alex Bozarth --- .github/scripts/cherry_pick_to_release.sh | 145 ------------------ .github/scripts/release.sh | 151 ++++++++++++++----- .github/workflows/cherry-pick-to-release.yml | 77 ---------- .github/workflows/ci.yml | 4 +- .github/workflows/cut-release-branch.yml | 1 + .github/workflows/docs-publish.yml | 5 +- RELEASE.md | 122 +++++++-------- 7 files changed, 180 insertions(+), 325 deletions(-) delete mode 100755 .github/scripts/cherry_pick_to_release.sh delete mode 100644 .github/workflows/cherry-pick-to-release.yml diff --git a/.github/scripts/cherry_pick_to_release.sh b/.github/scripts/cherry_pick_to_release.sh deleted file mode 100755 index 28e0dd7ac..000000000 --- a/.github/scripts/cherry_pick_to_release.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/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 [ ...] -# -# 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 -euo pipefail - -if [ "$#" -lt 2 ]; then - >&2 echo "usage: $0 [ ...]" - 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}") - 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 </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 - git cherry-pick --continue - 4. Push to origin (requires push access / bypass rights): - git push origin ${RELEASE_BRANCH} - -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 diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index a44abc641..c40aba08a 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -9,13 +9,22 @@ # GITHUB_REPOSITORY (required) — owner/repo, used for origin URL and links # CHGLOG_FILE (optional) — path to changelog (default: CHANGELOG.md) # ALLOW_MAIN_RELEASE=1 (optional) — bypass the main-branch guard +# PUBLISH_PRERELEASES (optional) — "true" to tag prereleases and upload to +# PyPI; default "false" leaves the bump +# commit as the only artifact. # -# Prereleases (rc, .dev): push a git tag. PyPI upload is handled by pypi.yml -# when the tag push fires, gated on PUBLISH_PRERELEASES. +# Prereleases (rc, .dev): when PUBLISH_PRERELEASES=true, push a git tag, +# create a prerelease GitHub Release with notes diffed against the previous +# tag (incremental), and dispatch pypi.yml. Otherwise no tag, no Release, +# no PyPI upload — the version-bump commit on the branch is the only +# artifact. # -# Finals: create a GitHub Release (tag + Release object + generated notes), -# append to the changelog on the release branch, and open a sync PR to main -# with the changelog delta. +# Finals: create a GitHub Release (tag + Release object + generated notes +# diffed against the previous final), append to the changelog on the release +# branch, and open a sync PR to main with the changelog delta. +# +# All write steps are idempotent so `bump_type=none` retries are safe after +# a partial failure. set -euo pipefail set -x @@ -30,57 +39,109 @@ if [ "${RELEASE_BRANCH}" = "main" ] && [ "${ALLOW_MAIN_RELEASE:-0}" != "1" ]; th fi CHGLOG_FILE="${CHGLOG_FILE:-CHANGELOG.md}" +PUBLISH_PRERELEASES="${PUBLISH_PRERELEASES:-false}" -# Pull the version from pyproject.toml — authoritative after bump_version.py ran. TARGET_VERSION=$(uvx --from=toml-cli toml get --toml-path=pyproject.toml project.version) TARGET_TAG_NAME="v${TARGET_VERSION}" -# Detect prerelease shape (rc or .dev). IS_PRERELEASE=0 if [[ "${TARGET_VERSION}" == *rc* ]] || [[ "${TARGET_VERSION}" == *.dev* ]]; then IS_PRERELEASE=1 fi +# START_TAG drives --notes-start-tag. Prereleases and finals serve +# different audiences and use different selection rules: +# - Prereleases (rc, dev): incremental — most recent reachable tag +# from HEAD. Steady state: previous rc on the release branch, previous +# dev on main. First-in-cycle (no reachable prior tag) leaves START_TAG +# empty so gh's default fills in. Testers want "what's new in rc2 +# since rc1"; the cumulative view lives on the final's Release. +# - Finals: cumulative — previous final regardless of intermediate +# prereleases. Selection by version shape: +# * Patch (Z>0): git describe excluding rc/dev (reachable on this +# branch; excludes a parallel-branch v0.7.0). +# * Minor (Z=0, Y>0): previous final is on a parallel release branch +# and isn't reachable; pick highest v(M).(Y-1).* by semver. +# * First minor (X.0.0): empty → gh's default ("latest non-prerelease"). +START_TAG="" +if [ "${IS_PRERELEASE}" = "1" ]; then + START_TAG=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || true) +elif [[ "${TARGET_VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + TGT_MAJOR="${BASH_REMATCH[1]}" + TGT_MINOR="${BASH_REMATCH[2]}" + TGT_PATCH="${BASH_REMATCH[3]}" + if [ "${TGT_PATCH}" -gt 0 ]; then + START_TAG=$(git describe --tags --abbrev=0 \ + --match 'v*' --exclude '*rc*' --exclude '*.dev*' \ + HEAD 2>/dev/null || true) + elif [ "${TGT_MINOR}" -gt 0 ]; then + PREV_MINOR=$((TGT_MINOR - 1)) + START_TAG=$(git tag -l "v${TGT_MAJOR}.${PREV_MINOR}.*" \ + | grep -vE '(rc|\.dev)' \ + | sort -V \ + | tail -1) + fi +fi + git config --global user.name 'github-actions[bot]' git config --global user.email 'github-actions[bot]@users.noreply.github.com' -# Configure the remote with the token for pushes. git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" -# Prerelease path: tag, push, dispatch pypi.yml. (Direct dispatch because -# GITHUB_TOKEN pushes don't fire downstream push: triggers; pypi.yml gates -# the upload on PUBLISH_PRERELEASES.) if [ "${IS_PRERELEASE}" = "1" ]; then - git tag "${TARGET_TAG_NAME}" - git push origin "${TARGET_TAG_NAME}" - gh workflow run pypi.yml --ref "${TARGET_TAG_NAME}" - echo "Tagged prerelease ${TARGET_TAG_NAME}" + if [ "${PUBLISH_PRERELEASES}" = "true" ]; then + if ! git rev-parse "refs/tags/${TARGET_TAG_NAME}" >/dev/null 2>&1; then + git tag "${TARGET_TAG_NAME}" + git push origin "${TARGET_TAG_NAME}" + fi + if ! gh release view "${TARGET_TAG_NAME}" >/dev/null 2>&1; then + if [ -n "${START_TAG}" ]; then + gh release create "${TARGET_TAG_NAME}" \ + --target "${RELEASE_BRANCH}" \ + --prerelease \ + --generate-notes \ + --notes-start-tag "${START_TAG}" + else + gh release create "${TARGET_TAG_NAME}" \ + --target "${RELEASE_BRANCH}" \ + --prerelease \ + --generate-notes + fi + fi + gh workflow run pypi.yml --ref "${TARGET_TAG_NAME}" + echo "Published prerelease ${TARGET_TAG_NAME} (tag + Release + PyPI)" + else + echo "Prerelease ${TARGET_TAG_NAME}: PUBLISH_PRERELEASES=false, skipping tag and PyPI upload" + fi exit 0 fi -# Final path. gh release create both tags and creates the Release object -# with notes generated against the previous Release. -gh release create "${TARGET_TAG_NAME}" \ - --target "${RELEASE_BRANCH}" \ - --generate-notes +if ! gh release view "${TARGET_TAG_NAME}" >/dev/null 2>&1; then + if [ -n "${START_TAG}" ]; then + gh release create "${TARGET_TAG_NAME}" \ + --target "${RELEASE_BRANCH}" \ + --generate-notes \ + --notes-start-tag "${START_TAG}" + else + gh release create "${TARGET_TAG_NAME}" \ + --target "${RELEASE_BRANCH}" \ + --generate-notes + fi +fi # Dispatch follow-on workflows directly (GITHUB_TOKEN-authored release/tag # events don't auto-trigger). gh workflow run pypi.yml --ref "${TARGET_TAG_NAME}" gh workflow run docs-publish.yml --field "release_tag=${TARGET_TAG_NAME}" -# Changelog sync PR is release-branch → main; skip it when publishing from -# main itself. if [ "${RELEASE_BRANCH}" = "main" ]; then echo "Published ${TARGET_TAG_NAME} from main — skipping changelog sync PR" exit 0 fi -# Pull the generated notes back locally to update the changelog. REL_NOTES=$(mktemp) gh release view "${TARGET_TAG_NAME}" --json body -q ".body" >> "${REL_NOTES}" -# Build the updated changelog. TMP_CHGLOG=$(mktemp) RELEASE_URL="$(gh repo view --json url -q ".url")/releases/tag/${TARGET_TAG_NAME}" printf "## [%s](%s) - %s\n\n" "${TARGET_TAG_NAME}" "${RELEASE_URL}" "$(date -Idate)" >> "${TMP_CHGLOG}" @@ -90,34 +151,44 @@ if [ -f "${CHGLOG_FILE}" ]; then fi mv "${TMP_CHGLOG}" "${CHGLOG_FILE}" -# Commit the changelog update to the release branch and push it. +# idempotent: skip if a prior run committed the same content. git add "${CHGLOG_FILE}" -git commit -m "docs: update changelog for ${TARGET_TAG_NAME} [skip ci]" -git push origin "${RELEASE_BRANCH}" +if ! git diff --cached --quiet; then + git commit -m "docs: update changelog for ${TARGET_TAG_NAME} [skip ci]" + git push origin "${RELEASE_BRANCH}" +fi -# Open a PR against main syncing just the changelog delta. Main is never -# pushed to directly from this script; branch protection applies normally. +# Main is never pushed to directly from this script; branch protection +# applies normally. SYNC_BRANCH="chore/changelog-sync-${TARGET_VERSION}" git fetch origin main -# If the sync branch already exists on origin (e.g. a previous run got past -# the push but failed before opening the PR), reuse it so we don't silently -# discard prior commits. Otherwise branch fresh from origin/main. +# Reuse an existing sync branch on origin so a partial prior run's commits +# aren't silently discarded. if git rev-parse --verify "refs/remotes/origin/${SYNC_BRANCH}" >/dev/null 2>&1; then git checkout "${SYNC_BRANCH}" git reset --hard "origin/${SYNC_BRANCH}" else git checkout -b "${SYNC_BRANCH}" origin/main fi -# Pick just the changelog change from the commit we just made on the release branch. git checkout "${RELEASE_BRANCH}" -- "${CHGLOG_FILE}" git add "${CHGLOG_FILE}" -git commit -m "docs: sync changelog for ${TARGET_TAG_NAME}" -git push origin "${SYNC_BRANCH}" +if ! git diff --cached --quiet; then + git commit -m "docs: sync changelog for ${TARGET_TAG_NAME}" + git push origin "${SYNC_BRANCH}" +fi -gh pr create \ - --base main \ - --head "${SYNC_BRANCH}" \ - --title "docs: sync changelog for ${TARGET_TAG_NAME}" \ - --body "Automated changelog sync from \`${RELEASE_BRANCH}\` after publishing [${TARGET_TAG_NAME}](${RELEASE_URL}). +EXISTING_PR=$(gh pr list --head "${SYNC_BRANCH}" --base main --state open --json number --jq '.[0].number // ""') +if [ -z "${EXISTING_PR}" ]; then + gh pr create \ + --base main \ + --head "${SYNC_BRANCH}" \ + --title "docs: sync changelog for ${TARGET_TAG_NAME}" \ + --body "Automated changelog sync from \`${RELEASE_BRANCH}\` after publishing [${TARGET_TAG_NAME}](${RELEASE_URL}). This PR brings the release-branch CHANGELOG entry back to main so the project root CHANGELOG remains the canonical history across all branches." +fi + +# GITHUB_TOKEN-authored pull_request events don't trigger workflows, so +# required status checks on main would block the sync PR forever. Dispatch +# CI explicitly against the sync branch. +gh workflow run ci.yml --ref "${SYNC_BRANCH}" diff --git a/.github/workflows/cherry-pick-to-release.yml b/.github/workflows/cherry-pick-to-release.yml deleted file mode 100644 index 9ff50cf23..000000000 --- a/.github/workflows/cherry-pick-to-release.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: "Cherry-pick to release branch" - -# Cherry-picks one or more commits from main onto a release branch via direct -# push. `github-actions[bot]` needs bypass rights on the release/** ruleset -# to push cherry-picked commits directly. -# -# Pushes authored by GITHUB_TOKEN don't fire downstream `push:` triggers -# (GitHub anti-loop rule), so CI on the release branch won't auto-run after -# a cherry-pick. The final step explicitly dispatches ci.yml against the -# target branch to work around this. -# -# On conflict the workflow fails with a clear playbook for local resolution. -# See RELEASE.md for the full patch-release flow. - -on: - workflow_dispatch: - inputs: - target_branch: - description: 'Release branch to cherry-pick onto (e.g. release/v0.6)' - type: string - required: true - shas: - description: 'Space- or comma-separated list of commit SHAs from main' - type: string - required: true - -permissions: {} - -env: - UV_FROZEN: "1" - CICD: 1 - -jobs: - cherry-pick: - environment: auto-release - permissions: - contents: write # push cherry-picks to release branch - actions: write # dispatch ci.yml after push - runs-on: ubuntu-latest - steps: - - name: Validate target branch - env: - TARGET_BRANCH: ${{ inputs.target_branch }} - run: | - if ! [[ "${TARGET_BRANCH}" =~ ^release/v ]]; then - echo "error: target_branch ${TARGET_BRANCH} must match release/v*" - exit 2 - fi - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ inputs.target_branch }} - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - persist-credentials: true - - name: Configure git user - run: | - git config --global user.name 'github-actions[bot]' - git config --global user.email 'github-actions[bot]@users.noreply.github.com' - - name: Cherry-pick and push - env: - AUTO_PUSH: "1" - INPUT_SHAS: ${{ inputs.shas }} - TARGET_BRANCH: ${{ inputs.target_branch }} - run: | - # Split commas or whitespace into an array of SHAs. - IFS=' ,' read -ra SHAS <<< "${INPUT_SHAS}" - if [ "${#SHAS[@]}" -eq 0 ]; then - echo "error: no SHAs provided" - exit 2 - fi - ./.github/scripts/cherry_pick_to_release.sh "${TARGET_BRANCH}" "${SHAS[@]}" - shell: bash - - name: Trigger CI on release branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TARGET_BRANCH: ${{ inputs.target_branch }} - run: gh workflow run ci.yml --ref "${TARGET_BRANCH}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5ffaca25..48da3aa66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,7 @@ on: merge_group: push: branches: ['release/**'] - # Dispatchable by cherry-pick-to-release.yml to run CI on direct-pushed - # cherry-picks. - workflow_dispatch: + workflow_dispatch: # used by release.sh for sync-PR CI (anti-loop workaround) inputs: ref: description: 'Branch or ref to run CI against' diff --git a/.github/workflows/cut-release-branch.yml b/.github/workflows/cut-release-branch.yml index 6d236c646..4990e17d2 100644 --- a/.github/workflows/cut-release-branch.yml +++ b/.github/workflows/cut-release-branch.yml @@ -32,6 +32,7 @@ jobs: pull-requests: write # release.sh may open changelog sync PR if rc0 is published (flag on) actions: write # release.sh dispatches pypi.yml after tagging rc0 runs-on: ubuntu-latest + concurrency: release steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml index 457381e86..5b48e9f49 100644 --- a/.github/workflows/docs-publish.yml +++ b/.github/workflows/docs-publish.yml @@ -344,6 +344,9 @@ jobs: # to the older minor's docs. - name: Check release is latest final id: latest_check + # Fail open: deploy if the check succeeded (true) or a transient API + # error left the output empty. Only skip on an explicit 'false'. + continue-on-error: true if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -431,7 +434,7 @@ jobs: # release_tag set; either path can request the skip. if: >- steps.latest_check.conclusion == 'skipped' || - steps.latest_check.outputs.is_latest_final == 'true' + steps.latest_check.outputs.is_latest_final != 'false' uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/RELEASE.md b/RELEASE.md index 297f01226..86dc1cbdb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -23,10 +23,10 @@ compatible with SemVer for final releases). | Phase | Branch | Version example | Tag | |-------|--------|-----------------|-----| | Dev on main | `main` | `0.6.0.dev0` | (untagged) | -| Release branch cut | `release/v0.6` | `0.6.0rc0` | `v0.6.0rc0` | -| Further RCs | `release/v0.6` | `0.6.0rc1`, `rc2`, … | `v0.6.0rcN` | +| Release branch cut | `release/v0.6` | `0.6.0rc0` | `v0.6.0rc0` if `PUBLISH_PRERELEASES`, else untagged | +| Further RCs | `release/v0.6` | `0.6.0rc1`, `rc2`, … | `v0.6.0rcN` if `PUBLISH_PRERELEASES`, else untagged | | Final minor | `release/v0.6` | `0.6.0` | `v0.6.0` | -| Patch RC | `release/v0.6` | `0.6.1rc0` | `v0.6.1rc0` | +| Patch RC | `release/v0.6` | `0.6.1rc0` | `v0.6.1rc0` if `PUBLISH_PRERELEASES`, else untagged | | Patch final | `release/v0.6` | `0.6.1` | `v0.6.1` | | Next minor dev on main | `main` | `0.7.0.dev0` | (untagged) | @@ -36,30 +36,40 @@ Invariants: publication runs (`publish-dev-from-main` workflow); never during routine commits. - Release branches always carry `X.Y.Zrc?` or `X.Y.Z`. -- Prereleases (`rcN`, `.devN`) always receive a git tag. PyPI upload is - governed by the `PUBLISH_PRERELEASES` repo variable (see below); - prereleases never produce a GitHub Release. +- Prereleases (`rcN`, `.devN`) are tagged, uploaded to PyPI, and given a + prerelease-marked GitHub Release only when the `PUBLISH_PRERELEASES` repo + variable is `true` (see below). With the default (`false`), the version + bump commits to the branch but no tag, no PyPI upload, and no Release. + Prerelease Releases use `--prerelease` so they don't appear as the + repo's "latest" release on GitHub. ## The `PUBLISH_PRERELEASES` flag -Repo variable `PUBLISH_PRERELEASES` (default `false`) governs PyPI upload -for prereleases. Prereleases never produce a GitHub Release; the flag only -gates PyPI. +Repo variable `PUBLISH_PRERELEASES` (default `false`) governs whether +prereleases are tagged, uploaded to PyPI, and given a GitHub Release. | `PUBLISH_PRERELEASES` | rc / dev | Finals | |-----------------------|----------|--------| -| `false` (default) | tag only | tag + GitHub Release + PyPI + changelog entry + sync PR | -| `true` | tag + PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | +| `false` (default) | version bump committed; no tag, no Release, no PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | +| `true` | tag + prerelease GitHub Release + PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | -Tags always push. Users can install any tagged prerelease via -`pip install git+https://github.com/generative-computing/mellea@v0.6.0rc1` -regardless of the flag. +With the default, prereleases stay branch-local — the bump commit identifies +the version but there is no immutable tag pointer. Users who need a specific +prerelease can install from a branch SHA +(`pip install git+https://github.com/generative-computing/mellea@`). + +When the flag is enabled, every rc and dev produces a `--prerelease`-marked +GitHub Release. The notes are incremental — `0.6.0rc2` diffs against +`0.6.0rc1`, so testers can see "what changed in this rc" without re-reading +the full cycle. The cumulative view ("everything in 0.6") shows up on the +final's Release page, which diffs against the previous final. Prerelease +Releases never become the repo's "latest" release. Finals always follow the full release flow regardless of the flag. -To enable prerelease publishing on PyPI, a repo admin sets the variable to -`true` under **Settings → Secrets and variables → Actions → Variables**. -No code change needed. +To enable prerelease publishing, a repo admin sets the variable to `true` +under **Settings → Secrets and variables → Actions → Variables**. No code +change needed. ## Workflows @@ -67,10 +77,9 @@ No code change needed. |----------|---------| | `cut-release-branch` | Cut `release/vX.Y` from `main`, publish `X.Y.0rc0`, bump `main` to next minor `.dev0` | | `publish-release` | Publish a release (rc, final, patch-rc, patch-final, or retry a failed publish) | -| `cherry-pick-to-release` | Cherry-pick commits from `main` onto a release branch | | `publish-dev-from-main` | Iterate main's `.devN` counter and publish a dev release | -All four are `workflow_dispatch`-only and run from the GitHub Actions UI. +All three are `workflow_dispatch`-only and run from the GitHub Actions UI. Whether any given prerelease (`rc`, `dev`) produces a PyPI artifact depends on the `PUBLISH_PRERELEASES` flag described above. @@ -89,8 +98,9 @@ The workflow: - Verifies `pyproject.toml` on `main` matches `X.Y.0.devN`. - Creates `release/vX.Y` with version set to `X.Y.0rc0`. -- Publishes `X.Y.0rc0` per the `PUBLISH_PRERELEASES` flag (tag-only by - default; tag + PyPI when enabled). +- Publishes `X.Y.0rc0` per the `PUBLISH_PRERELEASES` flag — by default the + version bump is committed to the release branch with no tag and no PyPI + upload; with the flag enabled, also tags and uploads. - Pushes `main` with version bumped to `X.(Y+1).0.dev0`. The `main` push requires `github-actions[bot]` to be listed as a bypass actor @@ -109,9 +119,13 @@ The workflow: - Computes the next rc (e.g. `0.6.0rc0` → `0.6.0rc1`). - Commits the bump to the release branch. -- Pushes tag `v{version}`. PyPI upload happens only when - `PUBLISH_PRERELEASES=true`. No GitHub Release object, no changelog entry, - no sync PR — those are reserved for finals. +- When `PUBLISH_PRERELEASES=true`: pushes tag `v{version}`, creates a + `--prerelease`-marked GitHub Release with incremental notes (diffed + against the previous rc), and uploads to PyPI. +- With the default (`false`): the bump commit is the only artifact — no + tag, no Release, no PyPI upload. +- Either way, no changelog entry, no sync PR — those are reserved for + finals. ## Promoting an RC to a final minor @@ -131,34 +145,15 @@ triggers the docs production deploy. Patches live on the original release branch. `main` is touched only when a `patch-final` lands and opens its changelog sync PR. -### 1. Cherry-pick fixes - -1. Identify the commit SHAs on `main` that need to go into the patch. -2. **Actions → Cherry-pick to release branch → Run workflow**. -3. `target_branch`: `release/v0.6`; `shas`: space- or comma-separated SHAs. -4. Run. - -The workflow topologically sorts the SHAs by their position in `git log main`, -cherry-picks with `git cherry-pick -x`, and pushes directly to the release -branch (`github-actions[bot]` needs bypass on `release/**`). It then -dispatches `ci.yml` explicitly against the release branch since -`GITHUB_TOKEN` pushes do not fire `push:` triggers on other workflows. - -If the workflow hits a conflict it fails with a resolution playbook. To -resolve: +### 1. Land the fix on the release branch -```bash -git fetch origin -git checkout release/v0.6 -git reset --hard origin/release/v0.6 -./.github/scripts/cherry_pick_to_release.sh release/v0.6 [ ...] -# Resolve conflicts: -git add -git cherry-pick --continue -git push origin release/v0.6 -``` +Open a PR targeting the release branch (e.g. `release/v0.6`). Branch +protection applies the same way as `main`: review + CI required. -Requires push access to `release/**` (or bypass). +If the same fix also belongs on `main`, open a separate followup PR — usually +after the release-branch PR merges, but the order can be flipped if it makes +review easier. Either direction works; the only constraint is that both +branches end up carrying the change so it doesn't regress in the next minor. ### 2. Publish a patch RC and final @@ -181,19 +176,21 @@ releases. The workflow (publish-then-increment): -1. Publishes `main`'s **current** `.devN` per `PUBLISH_PRERELEASES` — tag-only - by default, full release flow if enabled. The tag points at the current - `main` HEAD. +1. Publishes `main`'s **current** `.devN` per `PUBLISH_PRERELEASES` — skipped + by default; tag + prerelease GitHub Release + PyPI upload if enabled. + When tagged, the tag points at the current `main` HEAD. 2. Iterates pyproject on main: `X.Y.Z.devN → X.Y.Z.dev(N+1)`, commits, pushes. The invariant is that `main`'s pyproject always carries "the next version that would be published." Inspecting main tells you what the next dispatch will produce. -With `PUBLISH_PRERELEASES=false` (default) the outcome is a tag like -`v0.7.0.dev3` pointing at main HEAD and nothing else. With the flag enabled -it additionally uploads to PyPI (installable via `pip install --pre mellea`). -Dev publishes never create a GitHub Release or touch `CHANGELOG.md`. +With `PUBLISH_PRERELEASES=false` (default), the publish step is a no-op — +the `.devN` counter still advances, but no tag is pushed and nothing reaches +PyPI. With the flag enabled, the publish tags main HEAD, creates a +`--prerelease`-marked GitHub Release, and uploads to PyPI (installable via +`pip install --pre mellea`). Dev publishes never touch `CHANGELOG.md` and +never become the repo's "latest" release. ## Rollback and retry @@ -201,6 +198,13 @@ Dev publishes never create a GitHub Release or touch `CHANGELOG.md`. `pyproject.toml`, skipping the version-bump step. Useful when a previous run failed after the bump committed but before the publish completed. +The retry is a "skip what's done, finish what isn't" path — it does not +validate existing artifacts. If a prior run produced a tag pointing at the +wrong commit, a Release with stale notes, or a sync PR with a stale body, +delete the bad artifact (`gh release delete`, `git push --delete`, +`gh pr close`) before re-dispatching. Retry is for resuming after a partial +failure, not for fixing a corrupted prior result. + ## Release branch retention **Release branches are never deleted.** GitHub Releases pin to specific @@ -218,8 +222,8 @@ listed as a **bypass actor** on two rulesets: - `main`: `cut-release-branch` pushes the `X.(Y+1).0.dev0` bump directly; `publish-dev-from-main` pushes the `.dev(N+1)` advance commit directly. -- `release/**`: `publish-release` pushes the version-bump commit; `cherry-pick-to-release` - pushes cherry-picked commits directly. +- `release/**`: `publish-release` pushes the version-bump commit and (when + publishing a final) the changelog update. Recommended ruleset for `release/**`: From c7da42feac748793fbf8baa1d94eac7691c353a4 Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Mon, 18 May 2026 16:45:26 -0500 Subject: [PATCH 4/9] docs(release): restructure RELEASE.md as operator playbook Lead with the cut/stabilize/promote sequence; move versioning, PUBLISH_PRERELEASES, workflows, branch protection, retention, and docs-publish behavior into an appendix. Add a note on the manual prep for major version bumps. Assisted-by: Claude Code Signed-off-by: Alex Bozarth --- RELEASE.md | 279 ++++++++++++++++++++++++++++------------------------- 1 file changed, 149 insertions(+), 130 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 86dc1cbdb..4f4d59e4e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,94 +2,40 @@ ## Overview -Mellea uses a release-branch workflow. Every minor version has a long-lived -`release/vX.Y` branch that carries release candidates, the final minor release, -and any subsequent patch releases. `main` carries `.dev`-versioned work for the -next minor. +Mellea uses a release-branch workflow. Every release has a long-lived +`release/vX.Y` branch that carries release candidates, the final release, +and any subsequent patch releases. `main` carries `.dev`-versioned work for +the next release. -This gives each release a frozen codebase without requiring cherry-picks back -into `main`, and keeps CD resilient to concurrent merges on `main`. +This gives each release a frozen codebase and keeps CD resilient to concurrent +merges on `main`. ## Release Cadence -Minor releases target a roughly 4-week cadence. Patch releases happen as -needed. +Releases target a roughly 4-week cadence. Patch releases happen as needed. -## Versioning +## Cutting a release -Versions follow **[PEP 440](https://peps.python.org/pep-0440/)** (which is -compatible with SemVer for final releases). - -| Phase | Branch | Version example | Tag | -|-------|--------|-----------------|-----| -| Dev on main | `main` | `0.6.0.dev0` | (untagged) | -| Release branch cut | `release/v0.6` | `0.6.0rc0` | `v0.6.0rc0` if `PUBLISH_PRERELEASES`, else untagged | -| Further RCs | `release/v0.6` | `0.6.0rc1`, `rc2`, … | `v0.6.0rcN` if `PUBLISH_PRERELEASES`, else untagged | -| Final minor | `release/v0.6` | `0.6.0` | `v0.6.0` | -| Patch RC | `release/v0.6` | `0.6.1rc0` | `v0.6.1rc0` if `PUBLISH_PRERELEASES`, else untagged | -| Patch final | `release/v0.6` | `0.6.1` | `v0.6.1` | -| Next minor dev on main | `main` | `0.7.0.dev0` | (untagged) | - -Invariants: - -- `main` always carries `X.Y.0.devN`. `main` is tagged only when a dev - publication runs (`publish-dev-from-main` workflow); never during routine - commits. -- Release branches always carry `X.Y.Zrc?` or `X.Y.Z`. -- Prereleases (`rcN`, `.devN`) are tagged, uploaded to PyPI, and given a - prerelease-marked GitHub Release only when the `PUBLISH_PRERELEASES` repo - variable is `true` (see below). With the default (`false`), the version - bump commits to the branch but no tag, no PyPI upload, and no Release. - Prerelease Releases use `--prerelease` so they don't appear as the - repo's "latest" release on GitHub. - -## The `PUBLISH_PRERELEASES` flag - -Repo variable `PUBLISH_PRERELEASES` (default `false`) governs whether -prereleases are tagged, uploaded to PyPI, and given a GitHub Release. - -| `PUBLISH_PRERELEASES` | rc / dev | Finals | -|-----------------------|----------|--------| -| `false` (default) | version bump committed; no tag, no Release, no PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | -| `true` | tag + prerelease GitHub Release + PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | - -With the default, prereleases stay branch-local — the bump commit identifies -the version but there is no immutable tag pointer. Users who need a specific -prerelease can install from a branch SHA -(`pip install git+https://github.com/generative-computing/mellea@`). - -When the flag is enabled, every rc and dev produces a `--prerelease`-marked -GitHub Release. The notes are incremental — `0.6.0rc2` diffs against -`0.6.0rc1`, so testers can see "what changed in this rc" without re-reading -the full cycle. The cumulative view ("everything in 0.6") shows up on the -final's Release page, which diffs against the previous final. Prerelease -Releases never become the repo's "latest" release. - -Finals always follow the full release flow regardless of the flag. - -To enable prerelease publishing, a repo admin sets the variable to `true` -under **Settings → Secrets and variables → Actions → Variables**. No code -change needed. - -## Workflows - -| Workflow | Purpose | -|----------|---------| -| `cut-release-branch` | Cut `release/vX.Y` from `main`, publish `X.Y.0rc0`, bump `main` to next minor `.dev0` | -| `publish-release` | Publish a release (rc, final, patch-rc, patch-final, or retry a failed publish) | -| `publish-dev-from-main` | Iterate main's `.devN` counter and publish a dev release | +To ship `X.Y.0`: -All three are `workflow_dispatch`-only and run from the GitHub Actions UI. +1. Dispatch **Cut release branch** against `main` — creates `release/vX.Y` at + `X.Y.0rc0` and bumps `main` to `X.(Y+1).0.dev0`. +2. Stabilize on `release/vX.Y` by landing fixes via PRs targeting that branch + (open a separate followup PR to port the fix to `main` if it also belongs + there). +3. Dispatch **Publish release** against `release/vX.Y` with `bump_type: rc` to + produce each rc; repeat as needed. +4. Dispatch **Publish release** against `release/vX.Y` with `bump_type: final` + to ship `X.Y.0`. -Whether any given prerelease (`rc`, `dev`) produces a PyPI artifact depends -on the `PUBLISH_PRERELEASES` flag described above. +The per-step detail follows. -## Cutting a minor release branch +### 1. Cut the release branch -When `main` is ready to freeze for the next minor: +When `main` is ready to freeze for the next release: 1. Go to **Actions → Cut release branch → Run workflow**. -2. Optionally enter the expected minor (e.g. `0.6`) in `confirm_minor` as a +2. Optionally enter the expected X.Y (e.g. `0.6`) in `confirm_minor` as a safety check. Leave blank to trust whatever is in `pyproject.toml` on `main`. 3. Run. @@ -98,17 +44,31 @@ The workflow: - Verifies `pyproject.toml` on `main` matches `X.Y.0.devN`. - Creates `release/vX.Y` with version set to `X.Y.0rc0`. -- Publishes `X.Y.0rc0` per the `PUBLISH_PRERELEASES` flag — by default the - version bump is committed to the release branch with no tag and no PyPI - upload; with the flag enabled, also tags and uploads. -- Pushes `main` with version bumped to `X.(Y+1).0.dev0`. +- Publishes `X.Y.0rc0` per the [`PUBLISH_PRERELEASES`](#b-the-publish_prereleases-flag) + flag — by default the version bump is committed to the release branch with + no tag and no PyPI upload; with the flag enabled, also tags and uploads. +- Pushes `main` with version bumped to `X.(Y+1).0.dev0`. The push goes through + `github-actions[bot]`, which has bypass-actor permission on `main`'s ruleset + (see [Appendix D](#d-branch-protection)). -The `main` push requires `github-actions[bot]` to be listed as a bypass actor -in the `main` branch-protection ruleset (see **Branch protection** below). +To cut a major release (e.g. `1.0.0` from a `0.x` line), first land a regular +PR on `main` setting `pyproject.toml` to `(X+1).0.0.dev0`, then dispatch +**Cut release branch** as above. The workflow always bumps `main` by one +minor; the major bump itself is a manual step performed beforehand. -## Publishing a release candidate +### 2. Stabilize on the release branch -Once a release branch exists: +Stabilization fixes land on `release/vX.Y` via normal PRs targeting that +branch. Branch protection applies the same way as `main`: review + CI +required. + +If the same fix also belongs on `main`, open a separate followup PR — usually +after the release-branch PR merges, but the order can be flipped if it makes +review easier. Either direction works; the only constraint is that both +branches end up carrying the change so it doesn't regress in the next +release. + +To publish each rc: 1. Go to **Actions → Publish release → Run workflow**. 2. Select the release branch (e.g. `release/v0.6`) from the branch picker. @@ -127,9 +87,9 @@ The workflow: - Either way, no changelog entry, no sync PR — those are reserved for finals. -## Promoting an RC to a final minor +### 3. Promote to final -When testing on an RC is complete: +When testing on an rc is complete: 1. **Actions → Publish release → Run workflow** against the same release branch. 2. `bump_type: final`. @@ -145,25 +105,16 @@ triggers the docs production deploy. Patches live on the original release branch. `main` is touched only when a `patch-final` lands and opens its changelog sync PR. -### 1. Land the fix on the release branch +1. Land the fix on `release/vX.Y` via a PR targeting that branch (open a + separate followup PR to port to `main` if the fix also belongs there). +2. **Publish release** against `release/vX.Y` with `bump_type: patch-rc`. + Produces e.g. `v0.6.1rc0`. +3. Test. +4. Repeat **Publish release** with `bump_type: patch-rc` for additional rcs + if needed. +5. **Publish release** with `bump_type: patch-final` to promote to `v0.6.1`. -Open a PR targeting the release branch (e.g. `release/v0.6`). Branch -protection applies the same way as `main`: review + CI required. - -If the same fix also belongs on `main`, open a separate followup PR — usually -after the release-branch PR merges, but the order can be flipped if it makes -review easier. Either direction works; the only constraint is that both -branches end up carrying the change so it doesn't regress in the next minor. - -### 2. Publish a patch RC and final - -1. **Publish release** against `release/v0.6` with `bump_type: patch-rc`. Produces - e.g. `v0.6.1rc0`. -2. Test. -3. **Publish release** again with `bump_type: patch-rc` for additional rcs if needed. -4. **Publish release** with `bump_type: patch-final` to promote to `v0.6.1`. - -## Publishing a dev release from main +## Dev release from main Ad-hoc, case-by-case `.devN` bumps on `main`. Typical uses: a contributor wants a tagged snapshot of main for debugging, an external tester needs a @@ -205,44 +156,112 @@ delete the bad artifact (`gh release delete`, `git push --delete`, `gh pr close`) before re-dispatching. Retry is for resuming after a partial failure, not for fixing a corrupted prior result. -## Release branch retention +--- -**Release branches are never deleted.** GitHub Releases pin to specific -commits on each branch, so pruning a branch would orphan those references and -break `git checkout v0.4.2` semantics. Old `release/v0.3`, `release/v0.4`, etc. -stay around indefinitely. +# Appendix -## Branch protection +## A. Versioning -All four write-capable workflows authenticate via `secrets.GITHUB_TOKEN`. -Each declares the scopes it needs via an inline `permissions:` block. +Versions follow **[PEP 440](https://peps.python.org/pep-0440/)** (which is +compatible with SemVer for final releases). -`github-actions[bot]` (the identity `GITHUB_TOKEN` acts as) needs to be -listed as a **bypass actor** on two rulesets: +| Phase | Branch | Version example | Tag | +|-------|--------|-----------------|-----| +| Dev on main | `main` | `0.6.0.dev0` | (untagged) | +| Release branch cut | `release/v0.6` | `0.6.0rc0` | `v0.6.0rc0` if `PUBLISH_PRERELEASES`, else untagged | +| Further RCs | `release/v0.6` | `0.6.0rc1`, `rc2`, … | `v0.6.0rcN` if `PUBLISH_PRERELEASES`, else untagged | +| Final X.Y release | `release/v0.6` | `0.6.0` | `v0.6.0` | +| Patch RC | `release/v0.6` | `0.6.1rc0` | `v0.6.1rc0` if `PUBLISH_PRERELEASES`, else untagged | +| Patch final | `release/v0.6` | `0.6.1` | `v0.6.1` | +| Next dev on main | `main` | `0.7.0.dev0` | (untagged) | -- `main`: `cut-release-branch` pushes the `X.(Y+1).0.dev0` bump directly; - `publish-dev-from-main` pushes the `.dev(N+1)` advance commit directly. -- `release/**`: `publish-release` pushes the version-bump commit and (when - publishing a final) the changelog update. +Invariants: -Recommended ruleset for `release/**`: +- `main` always carries `X.Y.0.devN`. `main` is tagged only when a dev + publication runs (`publish-dev-from-main` workflow); never during routine + commits. +- Release branches always carry `X.Y.Zrc?` or `X.Y.Z`. +- Prereleases (`rcN`, `.devN`) are tagged, uploaded to PyPI, and given a + prerelease-marked GitHub Release only when the `PUBLISH_PRERELEASES` repo + variable is `true` (see [Appendix B](#b-the-publish_prereleases-flag)). + With the default (`false`), the version bump commits to the branch but no + tag, no PyPI upload, and no Release. Prerelease Releases use `--prerelease` + so they don't appear as the repo's "latest" release on GitHub. + +## B. The `PUBLISH_PRERELEASES` flag + +`PUBLISH_PRERELEASES` is a forward-looking feature flag. It exists so the +project can start publishing public prerelease artifacts later (likely +post-1.0) without a code change. It defaults to `false` and is expected to +stay `false` for the foreseeable future; the rest of this section documents +both states for when that changes. + +The flag is a repo variable that gates whether prereleases are tagged, +uploaded to PyPI, and given a GitHub Release. -- Require pull request review (bypassable by `github-actions[bot]`). -- Require status checks to pass (CI). -- No force-push, no deletion. +| `PUBLISH_PRERELEASES` | rc / dev | Finals | +|-----------------------|----------|--------| +| `false` (current) | version bump committed; no tag, no Release, no PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | +| `true` (future) | tag + prerelease GitHub Release + PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | + +While `false`, prereleases stay branch-local — the bump commit identifies +the version but there is no immutable tag pointer. Users who need a specific +prerelease can install from a branch SHA +(`pip install git+https://github.com/generative-computing/mellea@`). + +When the flag is eventually enabled, every rc and dev would produce a +`--prerelease`-marked GitHub Release. Notes would be incremental — +`0.6.0rc2` diffs against `0.6.0rc1`, so testers see "what changed in this +rc" without re-reading the full cycle. The cumulative view ("everything in +0.6") shows up on the final's Release page, which diffs against the previous +final. Prerelease Releases never become the repo's "latest" release. + +Finals always follow the full release flow regardless of the flag. + +## C. Workflow inventory + +| Workflow | Purpose | +|----------|---------| +| `cut-release-branch` | Cut `release/vX.Y` from `main`, publish `X.Y.0rc0`, bump `main` to the next `.dev0` | +| `publish-release` | Publish a release (rc, final, patch-rc, patch-final, or retry a failed publish) | +| `publish-dev-from-main` | Iterate main's `.devN` counter and publish a dev release | + +All three are `workflow_dispatch`-only and run from the GitHub Actions UI. + +Whether any given prerelease (`rc`, `dev`) produces a PyPI artifact depends +on the [`PUBLISH_PRERELEASES`](#b-the-publish_prereleases-flag) flag. + +## D. Branch protection + +All three write-capable workflows authenticate via `secrets.GITHUB_TOKEN`, +declaring scopes via inline `permissions:` blocks. The `GITHUB_TOKEN` +identity is `github-actions[bot]`, which is configured as a bypass actor on +the `main` and `release/**` rulesets so workflows can push directly: + +- `main`: `cut-release-branch` pushes the `X.(Y+1).0.dev0` bump; + `publish-dev-from-main` pushes the `.dev(N+1)` advance commit. +- `release/**`: `publish-release` pushes the version-bump commit and (for + finals) the changelog update. + +The `release/**` ruleset otherwise mirrors `main`: PR review required, +status checks (CI) required, no force-push, no deletion. + +## E. Release branch retention + +**Release branches are never deleted.** GitHub Releases pin to specific +commits on each branch, so pruning a branch would orphan those references and +break `git checkout v0.4.2` semantics. Old `release/v0.3`, `release/v0.4`, etc. +stay around indefinitely. + +## F. Docs behavior by release type Docs publishing (`docs-publish.yml`) deploys to `docs/production` only when a published GitHub Release is the latest final by semver, so older-branch patches don't overwrite production docs. -## Docs behavior by release type - | Release type | docs/production | docs/staging | |--------------|-----------------|--------------| | RC (`v0.6.0rc0`) | unchanged | unchanged | -| Final minor (`v0.6.0`) | deployed | (main-push rebuilds as usual) | -| Patch on latest minor (`v0.6.1` after `v0.6.0`) | deployed | unchanged | -| Patch on older minor (`v0.5.1` after `v0.6.0`) | unchanged | unchanged | - -Versioned docs (per-minor URL prefixes and a version switcher) would supersede -the latest-final-by-semver gate; not in scope here. +| Final X.Y release (`v0.6.0`) | deployed | (main-push rebuilds as usual) | +| Patch on latest X.Y (`v0.6.1` after `v0.6.0`) | deployed | unchanged | +| Patch on older X.Y (`v0.5.1` after `v0.6.0`) | unchanged | unchanged | From 41427da22ebe25b1c9ed0b7985255366f505f45a Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Mon, 18 May 2026 16:48:11 -0500 Subject: [PATCH 5/9] docs(release): replace ambiguous "CD" with concrete workflow names The cd.yml workflow was renamed to publish-release in this branch, and the release flow is workflow_dispatch-only rather than continuous. Use "release publishing" and "publish-release" directly. Assisted-by: Claude Code Signed-off-by: Alex Bozarth --- RELEASE.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 4f4d59e4e..b584cfd05 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -7,8 +7,8 @@ Mellea uses a release-branch workflow. Every release has a long-lived and any subsequent patch releases. `main` carries `.dev`-versioned work for the next release. -This gives each release a frozen codebase and keeps CD resilient to concurrent -merges on `main`. +This gives each release a frozen codebase and keeps release publishing +resilient to concurrent merges on `main`. ## Release Cadence @@ -145,9 +145,10 @@ never become the repo's "latest" release. ## Rollback and retry -`bump_type: none` re-runs CD against whatever version is currently in -`pyproject.toml`, skipping the version-bump step. Useful when a previous -run failed after the bump committed but before the publish completed. +`bump_type: none` re-runs `publish-release` against whatever version is +currently in `pyproject.toml`, skipping the version-bump step. Useful when +a previous run failed after the bump committed but before the publish +completed. The retry is a "skip what's done, finish what isn't" path — it does not validate existing artifacts. If a prior run produced a tag pointing at the From 1e0b4e0dbeaeec6da544b73f2cb79b4b6b4ae745 Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Wed, 20 May 2026 10:52:24 -0500 Subject: [PATCH 6/9] chore: bump dev version to 0.7.0.dev0 The 0.6.0.dev0 marker on this branch became stale once 0.6.0 shipped to PyPI. Advance to 0.7.0.dev0 so the next release cut produces 0.7.0rc0. Assisted-by: Claude Code Signed-off-by: Alex Bozarth --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c907d8bb9..fcdb8532a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "pdm.backend" [project] name = "mellea" -version = "0.6.0.dev0" +version = "0.7.0.dev0" authors = [ { name = "Mellea Contributors", email = "melleaadmin@ibm.com" }, ] diff --git a/uv.lock b/uv.lock index bf7e473cd..8d1002af6 100644 --- a/uv.lock +++ b/uv.lock @@ -3198,7 +3198,7 @@ wheels = [ [[package]] name = "mellea" -version = "0.6.0.dev0" +version = "0.7.0.dev0" source = { editable = "." } dependencies = [ { name = "jinja2" }, From 6637cd390783cc1c8fb7a585440c137f87e8057a Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Wed, 20 May 2026 15:25:55 -0500 Subject: [PATCH 7/9] refactor: inline run helper in bump_version.py The helper was only used twice; the other three subprocess.run calls need capture_output or a custom env, so the helper couldn't reduce those. Drop the indirection and inline. Assisted-by: Claude Code Signed-off-by: Alex Bozarth --- .github/scripts/bump_version.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/scripts/bump_version.py b/.github/scripts/bump_version.py index ed2b27067..63f52a524 100755 --- a/.github/scripts/bump_version.py +++ b/.github/scripts/bump_version.py @@ -146,10 +146,6 @@ def write_pyproject(new_version: Version) -> None: 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( @@ -213,8 +209,14 @@ def main() -> int: check=True, env={**os.environ, "UV_FROZEN": "0"}, ) - run(["git", "add", "pyproject.toml", "uv.lock"]) - run(["git", "commit", "-m", f"release: bump version to {next_version} [skip ci]"]) + 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 From 901a1273873d575a752a24513b21e489fc7fe1e9 Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Wed, 20 May 2026 16:31:03 -0500 Subject: [PATCH 8/9] docs: restructure RELEASE.md as step-by-step operator guide Reorganized into minor release / patch release / troubleshooting / appendix sections with per-step subsections, per Paul's review feedback. Stripped workflow internals from the body and pulled rc cycling, the major-bump corner case, retry/rollback, and the ad-hoc dev publish into appendix or troubleshooting. Action links now point to the workflow run pages rather than the YAML source. Assisted-by: Claude Code Signed-off-by: Alex Bozarth --- RELEASE.md | 324 +++++++++++++++++++++++++---------------------------- 1 file changed, 154 insertions(+), 170 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index b584cfd05..3765fbad6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,167 +1,131 @@ # RELEASE.md -## Overview +Mellea uses a release-branch workflow. Each minor release lives on a +long-lived `release/vX.Y` branch that carries the final release and any +later patches. `main` carries `.devN`-versioned work for the next minor. +Releases target a roughly 4-week cadence; patch releases happen as needed. -Mellea uses a release-branch workflow. Every release has a long-lived -`release/vX.Y` branch that carries release candidates, the final release, -and any subsequent patch releases. `main` carries `.dev`-versioned work for -the next release. +- [Making a minor release](#making-a-minor-release) +- [Making a patch release](#making-a-patch-release) +- [Troubleshooting](#troubleshooting) +- [Appendix](#appendix) -This gives each release a frozen codebase and keeps release publishing -resilient to concurrent merges on `main`. +## Making a minor release -## Release Cadence +1. [Cut the release branch](#cut-the-release-branch) +2. [Stabilize on the release branch](#stabilize-on-the-release-branch) +3. [Publish the final](#publish-the-final) +4. [Sync the changelog back to main](#sync-the-changelog-back-to-main) -Releases target a roughly 4-week cadence. Patch releases happen as needed. +### Cut the release branch -## Cutting a release +Run [Cut release branch](https://github.com/generative-computing/mellea/actions/workflows/cut-release-branch.yml) +against `main`. -To ship `X.Y.0`: +- **Optional**: enter the expected `X.Y` (e.g. `0.6`) in `confirm_minor` as + a sanity check. Leave blank to trust whatever is in `pyproject.toml`. -1. Dispatch **Cut release branch** against `main` — creates `release/vX.Y` at - `X.Y.0rc0` and bumps `main` to `X.(Y+1).0.dev0`. -2. Stabilize on `release/vX.Y` by landing fixes via PRs targeting that branch - (open a separate followup PR to port the fix to `main` if it also belongs - there). -3. Dispatch **Publish release** against `release/vX.Y` with `bump_type: rc` to - produce each rc; repeat as needed. -4. Dispatch **Publish release** against `release/vX.Y` with `bump_type: final` - to ship `X.Y.0`. +After this runs: `release/vX.Y` exists at `X.Y.0rc0`, and `main` has been +bumped to `X.(Y+1).0.dev0`. -The per-step detail follows. +### Stabilize on the release branch -### 1. Cut the release branch +Land fixes via PRs targeting `release/vX.Y` (normal review + CI; protection +mirrors `main`). If the same change also belongs on `main`, open a +follow-up PR to `main` once the release-branch PR is merged. -When `main` is ready to freeze for the next release: +> RC iteration during stabilization (`bump_type: rc`) is currently a no-op: +> the rc counter advances in `pyproject.toml` but nothing is published. +> See [rc cycling](#rc-cycling) in the appendix for what changes when +> prereleases are enabled. -1. Go to **Actions → Cut release branch → Run workflow**. -2. Optionally enter the expected X.Y (e.g. `0.6`) in `confirm_minor` as a - safety check. Leave blank to trust whatever is in `pyproject.toml` on - `main`. -3. Run. +### Publish the final -The workflow: +Run [Publish release](https://github.com/generative-computing/mellea/actions/workflows/publish-release.yml) +against `release/vX.Y`. -- Verifies `pyproject.toml` on `main` matches `X.Y.0.devN`. -- Creates `release/vX.Y` with version set to `X.Y.0rc0`. -- Publishes `X.Y.0rc0` per the [`PUBLISH_PRERELEASES`](#b-the-publish_prereleases-flag) - flag — by default the version bump is committed to the release branch with - no tag and no PyPI upload; with the flag enabled, also tags and uploads. -- Pushes `main` with version bumped to `X.(Y+1).0.dev0`. The push goes through - `github-actions[bot]`, which has bypass-actor permission on `main`'s ruleset - (see [Appendix D](#d-branch-protection)). +- `bump_type`: `final` -To cut a major release (e.g. `1.0.0` from a `0.x` line), first land a regular -PR on `main` setting `pyproject.toml` to `(X+1).0.0.dev0`, then dispatch -**Cut release branch** as above. The workflow always bumps `main` by one -minor; the major bump itself is a manual step performed beforehand. +After this runs: `vX.Y.0` is tagged, the GitHub Release exists, the PyPI +upload is done, and docs/production has been redeployed. -### 2. Stabilize on the release branch +### Sync the changelog back to main -Stabilization fixes land on `release/vX.Y` via normal PRs targeting that -branch. Branch protection applies the same way as `main`: review + CI -required. +The publish workflow opens a PR titled `docs: sync changelog for vX.Y.0` +from `chore/changelog-sync-X.Y.0` to `main`. Review and merge it. -If the same fix also belongs on `main`, open a separate followup PR — usually -after the release-branch PR merges, but the order can be flipped if it makes -review easier. Either direction works; the only constraint is that both -branches end up carrying the change so it doesn't regress in the next -release. - -To publish each rc: +## Making a patch release -1. Go to **Actions → Publish release → Run workflow**. -2. Select the release branch (e.g. `release/v0.6`) from the branch picker. -3. Choose `bump_type: rc`. -4. Run. +1. [Land fixes on the release branch](#land-fixes-on-the-release-branch) +2. [Publish the patch](#publish-the-patch) +3. [Sync the changelog back to main](#sync-the-changelog-back-to-main-1) -The workflow: +### Land fixes on the release branch -- Computes the next rc (e.g. `0.6.0rc0` → `0.6.0rc1`). -- Commits the bump to the release branch. -- When `PUBLISH_PRERELEASES=true`: pushes tag `v{version}`, creates a - `--prerelease`-marked GitHub Release with incremental notes (diffed - against the previous rc), and uploads to PyPI. -- With the default (`false`): the bump commit is the only artifact — no - tag, no Release, no PyPI upload. -- Either way, no changelog entry, no sync PR — those are reserved for - finals. +PR targeting `release/vX.Y`. If the change also belongs on `main`, open a +follow-up PR to `main` once the release-branch PR is merged. -### 3. Promote to final +### Publish the patch -When testing on an rc is complete: +Run [Publish release](https://github.com/generative-computing/mellea/actions/workflows/publish-release.yml) +against `release/vX.Y`. -1. **Actions → Publish release → Run workflow** against the same release branch. -2. `bump_type: final`. -3. Run. +- `bump_type`: `patch-final` -This creates the `v0.6.0` GitHub Release (with auto-generated notes from -the previous final), uploads to PyPI, appends to `CHANGELOG.md` on the -release branch, opens a sync PR to `main` with the changelog delta, and -triggers the docs production deploy. +> Patch rc iteration (`bump_type: patch-rc`) is currently a no-op for the +> same reason as minor rc iteration; see [rc cycling](#rc-cycling). -## Patch releases +### Sync the changelog back to main -Patches live on the original release branch. `main` is touched only when a -`patch-final` lands and opens its changelog sync PR. +Same as the minor flow: review and merge the auto-opened +`chore/changelog-sync-X.Y.Z` PR. -1. Land the fix on `release/vX.Y` via a PR targeting that branch (open a - separate followup PR to port to `main` if the fix also belongs there). -2. **Publish release** against `release/vX.Y` with `bump_type: patch-rc`. - Produces e.g. `v0.6.1rc0`. -3. Test. -4. Repeat **Publish release** with `bump_type: patch-rc` for additional rcs - if needed. -5. **Publish release** with `bump_type: patch-final` to promote to `v0.6.1`. +## Troubleshooting -## Dev release from main +### Retry a failed publish -Ad-hoc, case-by-case `.devN` bumps on `main`. Typical uses: a contributor -wants a tagged snapshot of main for debugging, an external tester needs a -specific point-in-time artifact, etc. Not intended for routine or scheduled -releases. +`bump_type: none` re-runs `publish-release` against whatever version is +already in `pyproject.toml`, skipping the version-bump step. Useful when a +previous run failed after the bump committed but before the publish +completed. -1. **Actions → Publish dev release from main → Run workflow** (must dispatch - against `main`). -2. Run. +The retry is a "skip what's done, finish what isn't" path — it does not +validate existing artifacts. If a prior run produced a tag at the wrong +commit, a Release with stale notes, or a sync PR with a stale body, delete +the bad artifact (`gh release delete`, `git push --delete`, `gh pr close`) +before re-dispatching. Retry is for resuming after a partial failure, not +for fixing a corrupted result. -The workflow (publish-then-increment): +### Cutting a major release -1. Publishes `main`'s **current** `.devN` per `PUBLISH_PRERELEASES` — skipped - by default; tag + prerelease GitHub Release + PyPI upload if enabled. - When tagged, the tag points at the current `main` HEAD. -2. Iterates pyproject on main: `X.Y.Z.devN → X.Y.Z.dev(N+1)`, commits, pushes. +The cut-release workflow always bumps `main` by one minor. To cut a major +(e.g. `1.0.0` from a `0.x` line): -The invariant is that `main`'s pyproject always carries "the next version -that would be published." Inspecting main tells you what the next dispatch -will produce. +1. Land a regular PR on `main` setting `pyproject.toml` to + `(X+1).0.0.dev0`. +2. Dispatch [Cut release branch](https://github.com/generative-computing/mellea/actions/workflows/cut-release-branch.yml) + as usual. -With `PUBLISH_PRERELEASES=false` (default), the publish step is a no-op — -the `.devN` counter still advances, but no tag is pushed and nothing reaches -PyPI. With the flag enabled, the publish tags main HEAD, creates a -`--prerelease`-marked GitHub Release, and uploads to PyPI (installable via -`pip install --pre mellea`). Dev publishes never touch `CHANGELOG.md` and -never become the repo's "latest" release. +### Ad-hoc dev publish from main -## Rollback and retry +Used when someone needs a tagged point-in-time artifact of `main` outside +the normal release cadence. With `PUBLISH_PRERELEASES=false` (the default) +this workflow is a no-op for publishing; the only effect is that `main`'s +`.devN` counter advances. -`bump_type: none` re-runs `publish-release` against whatever version is -currently in `pyproject.toml`, skipping the version-bump step. Useful when -a previous run failed after the bump committed but before the publish -completed. +Run [Publish dev release from main](https://github.com/generative-computing/mellea/actions/workflows/publish-dev-from-main.yml) +against `main`. With the flag enabled, the workflow tags `main` HEAD, +creates a prerelease GitHub Release, and uploads to PyPI. Either way, it +then bumps `main` from `X.Y.Z.devN` to `X.Y.Z.dev(N+1)` and pushes. -The retry is a "skip what's done, finish what isn't" path — it does not -validate existing artifacts. If a prior run produced a tag pointing at the -wrong commit, a Release with stale notes, or a sync PR with a stale body, -delete the bad artifact (`gh release delete`, `git push --delete`, -`gh pr close`) before re-dispatching. Retry is for resuming after a partial -failure, not for fixing a corrupted prior result. +`main`'s `pyproject.toml` always reflects the version that the next +dispatch will publish. --- # Appendix -## A. Versioning +## Versioning Versions follow **[PEP 440](https://peps.python.org/pep-0440/)** (which is compatible with SemVer for final releases). @@ -179,65 +143,34 @@ compatible with SemVer for final releases). Invariants: - `main` always carries `X.Y.0.devN`. `main` is tagged only when a dev - publication runs (`publish-dev-from-main` workflow); never during routine - commits. + publication runs; never during routine commits. - Release branches always carry `X.Y.Zrc?` or `X.Y.Z`. - Prereleases (`rcN`, `.devN`) are tagged, uploaded to PyPI, and given a - prerelease-marked GitHub Release only when the `PUBLISH_PRERELEASES` repo - variable is `true` (see [Appendix B](#b-the-publish_prereleases-flag)). - With the default (`false`), the version bump commits to the branch but no - tag, no PyPI upload, and no Release. Prerelease Releases use `--prerelease` - so they don't appear as the repo's "latest" release on GitHub. - -## B. The `PUBLISH_PRERELEASES` flag + prerelease-marked GitHub Release only when + [`PUBLISH_PRERELEASES`](#the-publish_prereleases-flag) is `true`. With the + default (`false`), the version bump commits to the branch but no tag, no + PyPI upload, and no Release. Prerelease Releases use `--prerelease` so + they don't appear as "latest" on GitHub. -`PUBLISH_PRERELEASES` is a forward-looking feature flag. It exists so the -project can start publishing public prerelease artifacts later (likely -post-1.0) without a code change. It defaults to `false` and is expected to -stay `false` for the foreseeable future; the rest of this section documents -both states for when that changes. - -The flag is a repo variable that gates whether prereleases are tagged, -uploaded to PyPI, and given a GitHub Release. - -| `PUBLISH_PRERELEASES` | rc / dev | Finals | -|-----------------------|----------|--------| -| `false` (current) | version bump committed; no tag, no Release, no PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | -| `true` (future) | tag + prerelease GitHub Release + PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | - -While `false`, prereleases stay branch-local — the bump commit identifies -the version but there is no immutable tag pointer. Users who need a specific -prerelease can install from a branch SHA -(`pip install git+https://github.com/generative-computing/mellea@`). - -When the flag is eventually enabled, every rc and dev would produce a -`--prerelease`-marked GitHub Release. Notes would be incremental — -`0.6.0rc2` diffs against `0.6.0rc1`, so testers see "what changed in this -rc" without re-reading the full cycle. The cumulative view ("everything in -0.6") shows up on the final's Release page, which diffs against the previous -final. Prerelease Releases never become the repo's "latest" release. - -Finals always follow the full release flow regardless of the flag. - -## C. Workflow inventory +## Workflow inventory | Workflow | Purpose | |----------|---------| -| `cut-release-branch` | Cut `release/vX.Y` from `main`, publish `X.Y.0rc0`, bump `main` to the next `.dev0` | -| `publish-release` | Publish a release (rc, final, patch-rc, patch-final, or retry a failed publish) | -| `publish-dev-from-main` | Iterate main's `.devN` counter and publish a dev release | +| [`cut-release-branch`](.github/workflows/cut-release-branch.yml) | Cut `release/vX.Y` from `main`, publish `X.Y.0rc0`, bump `main` to the next `.dev0` | +| [`publish-release`](.github/workflows/publish-release.yml) | Publish a release (rc, final, patch-rc, patch-final, or retry a failed publish) | +| [`publish-dev-from-main`](.github/workflows/publish-dev-from-main.yml) | Iterate main's `.devN` counter and (when enabled) publish a dev release | All three are `workflow_dispatch`-only and run from the GitHub Actions UI. Whether any given prerelease (`rc`, `dev`) produces a PyPI artifact depends -on the [`PUBLISH_PRERELEASES`](#b-the-publish_prereleases-flag) flag. +on the [`PUBLISH_PRERELEASES`](#the-publish_prereleases-flag) flag. -## D. Branch protection +## Branch protection All three write-capable workflows authenticate via `secrets.GITHUB_TOKEN`, declaring scopes via inline `permissions:` blocks. The `GITHUB_TOKEN` -identity is `github-actions[bot]`, which is configured as a bypass actor on -the `main` and `release/**` rulesets so workflows can push directly: +identity is `github-actions[bot]`, configured as a bypass actor on the +`main` and `release/**` rulesets so workflows can push directly: - `main`: `cut-release-branch` pushes the `X.(Y+1).0.dev0` bump; `publish-dev-from-main` pushes the `.dev(N+1)` advance commit. @@ -247,14 +180,14 @@ the `main` and `release/**` rulesets so workflows can push directly: The `release/**` ruleset otherwise mirrors `main`: PR review required, status checks (CI) required, no force-push, no deletion. -## E. Release branch retention +## Release branch retention **Release branches are never deleted.** GitHub Releases pin to specific -commits on each branch, so pruning a branch would orphan those references and -break `git checkout v0.4.2` semantics. Old `release/v0.3`, `release/v0.4`, etc. -stay around indefinitely. +commits on each branch, so pruning a branch would orphan those references +and break `git checkout v0.4.2` semantics. Old `release/v0.3`, +`release/v0.4`, etc. stay around indefinitely. -## F. Docs behavior by release type +## Docs behavior by release type Docs publishing (`docs-publish.yml`) deploys to `docs/production` only when a published GitHub Release is the latest final by semver, so older-branch @@ -266,3 +199,54 @@ patches don't overwrite production docs. | Final X.Y release (`v0.6.0`) | deployed | (main-push rebuilds as usual) | | Patch on latest X.Y (`v0.6.1` after `v0.6.0`) | deployed | unchanged | | Patch on older X.Y (`v0.5.1` after `v0.6.0`) | unchanged | unchanged | + +## RC cycling + +`bump_type: rc` (minor) and `bump_type: patch-rc` (patch) iterate the rc +counter on the release branch: + +- **`PUBLISH_PRERELEASES=false`** (current default): the rc counter + advances in `pyproject.toml`, but no tag is pushed, no Release is + created, and nothing reaches PyPI. The rc step is effectively a no-op + during stabilization, so the documented flow above goes straight from + cut to final. +- **`PUBLISH_PRERELEASES=true`** (future): each `rc` dispatch tags + `vX.Y.ZrcN`, creates a `--prerelease` GitHub Release with notes diffed + against the previous rc, and uploads to PyPI. + +When `PUBLISH_PRERELEASES` is enabled, the rc step is a real publish and +fits between [Stabilize](#stabilize-on-the-release-branch) and +[Publish the final](#publish-the-final): dispatch +[Publish release](https://github.com/generative-computing/mellea/actions/workflows/publish-release.yml) with +`bump_type: rc` (or `patch-rc`) as many times as needed. + +## The `PUBLISH_PRERELEASES` flag + +`PUBLISH_PRERELEASES` is a forward-looking feature flag. It exists so the +project can start publishing public prerelease artifacts later (likely +post-1.0) without a code change. It defaults to `false` and is expected to +stay `false` for the foreseeable future; the rest of this section +documents both states for when that changes. + +The flag is a repo variable that gates whether prereleases are tagged, +uploaded to PyPI, and given a GitHub Release. + +| `PUBLISH_PRERELEASES` | rc / dev | Finals | +|-----------------------|----------|--------| +| `false` (current) | version bump committed; no tag, no Release, no PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | +| `true` (future) | tag + prerelease GitHub Release + PyPI | tag + GitHub Release + PyPI + changelog entry + sync PR | + +While `false`, prereleases stay branch-local — the bump commit identifies +the version but there is no immutable tag pointer. Users who need a +specific prerelease can install from a branch SHA +(`pip install git+https://github.com/generative-computing/mellea@`). + +When the flag is eventually enabled, every rc and dev produces a +`--prerelease`-marked GitHub Release. Notes are incremental — +`0.6.0rc2` diffs against `0.6.0rc1`, so testers see "what changed in this +rc" without re-reading the full cycle. The cumulative view ("everything +in 0.6") shows up on the final's Release page, which diffs against the +previous final. Prerelease Releases never become the repo's "latest" +release. + +Finals always follow the full release flow regardless of the flag. From 0709a38e4e0ea46329c85c4fca5b6ac085bd5c23 Mon Sep 17 00:00:00 2001 From: Alex Bozarth Date: Wed, 20 May 2026 17:19:23 -0500 Subject: [PATCH 9/9] docs: number step headings in RELEASE.md release flows Per Paul's nit: TOC entries are numbered, so the heading text should match. Numbered the H3s under Making a minor release and Making a patch release, plus updated cross-references in the rc cycling appendix. Assisted-by: Claude Code Signed-off-by: Alex Bozarth --- RELEASE.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 3765fbad6..340445c77 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -12,12 +12,12 @@ Releases target a roughly 4-week cadence; patch releases happen as needed. ## Making a minor release -1. [Cut the release branch](#cut-the-release-branch) -2. [Stabilize on the release branch](#stabilize-on-the-release-branch) -3. [Publish the final](#publish-the-final) -4. [Sync the changelog back to main](#sync-the-changelog-back-to-main) +1. [Cut the release branch](#1-cut-the-release-branch) +2. [Stabilize on the release branch](#2-stabilize-on-the-release-branch) +3. [Publish the final](#3-publish-the-final) +4. [Sync the changelog back to main](#4-sync-the-changelog-back-to-main) -### Cut the release branch +### 1. Cut the release branch Run [Cut release branch](https://github.com/generative-computing/mellea/actions/workflows/cut-release-branch.yml) against `main`. @@ -28,7 +28,7 @@ against `main`. After this runs: `release/vX.Y` exists at `X.Y.0rc0`, and `main` has been bumped to `X.(Y+1).0.dev0`. -### Stabilize on the release branch +### 2. Stabilize on the release branch Land fixes via PRs targeting `release/vX.Y` (normal review + CI; protection mirrors `main`). If the same change also belongs on `main`, open a @@ -39,7 +39,7 @@ follow-up PR to `main` once the release-branch PR is merged. > See [rc cycling](#rc-cycling) in the appendix for what changes when > prereleases are enabled. -### Publish the final +### 3. Publish the final Run [Publish release](https://github.com/generative-computing/mellea/actions/workflows/publish-release.yml) against `release/vX.Y`. @@ -49,23 +49,23 @@ against `release/vX.Y`. After this runs: `vX.Y.0` is tagged, the GitHub Release exists, the PyPI upload is done, and docs/production has been redeployed. -### Sync the changelog back to main +### 4. Sync the changelog back to main The publish workflow opens a PR titled `docs: sync changelog for vX.Y.0` from `chore/changelog-sync-X.Y.0` to `main`. Review and merge it. ## Making a patch release -1. [Land fixes on the release branch](#land-fixes-on-the-release-branch) -2. [Publish the patch](#publish-the-patch) -3. [Sync the changelog back to main](#sync-the-changelog-back-to-main-1) +1. [Land fixes on the release branch](#1-land-fixes-on-the-release-branch) +2. [Publish the patch](#2-publish-the-patch) +3. [Sync the changelog back to main](#3-sync-the-changelog-back-to-main) -### Land fixes on the release branch +### 1. Land fixes on the release branch PR targeting `release/vX.Y`. If the change also belongs on `main`, open a follow-up PR to `main` once the release-branch PR is merged. -### Publish the patch +### 2. Publish the patch Run [Publish release](https://github.com/generative-computing/mellea/actions/workflows/publish-release.yml) against `release/vX.Y`. @@ -75,7 +75,7 @@ against `release/vX.Y`. > Patch rc iteration (`bump_type: patch-rc`) is currently a no-op for the > same reason as minor rc iteration; see [rc cycling](#rc-cycling). -### Sync the changelog back to main +### 3. Sync the changelog back to main Same as the minor flow: review and merge the auto-opened `chore/changelog-sync-X.Y.Z` PR. @@ -215,8 +215,8 @@ counter on the release branch: against the previous rc, and uploads to PyPI. When `PUBLISH_PRERELEASES` is enabled, the rc step is a real publish and -fits between [Stabilize](#stabilize-on-the-release-branch) and -[Publish the final](#publish-the-final): dispatch +fits between [Stabilize](#2-stabilize-on-the-release-branch) and +[Publish the final](#3-publish-the-final): dispatch [Publish release](https://github.com/generative-computing/mellea/actions/workflows/publish-release.yml) with `bump_type: rc` (or `patch-rc`) as many times as needed.