diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 0000000..3f7ff55 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,85 @@ +name: Bump version + +# After a non-release PR merges to main, open (or refresh) a release PR that +# bumps pyproject.toml + uv.lock ahead of PyPI. Merging that PR runs publish.yml. +on: + pull_request: + types: [closed] + branches: [main] + workflow_dispatch: + inputs: + bump: + description: Semver component to increment + type: choice + options: [patch, minor, major] + default: patch + +concurrency: + group: bump-version + cancel-in-progress: true + +jobs: + bump: + if: > + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + !contains(github.event.pull_request.labels.*.name, 'release')) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && 'main' || github.event.pull_request.merge_commit_sha }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Plan and write bump + id: bump + run: | + set -euo pipefail + KIND="${{ github.event_name == 'workflow_dispatch' && inputs.bump || 'patch' }}" + VERSION=$(python scripts/bump_version.py --bump "$KIND" --write | tail -n 1) + if [ -z "$VERSION" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Open or update release PR + if: steps.bump.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.bump.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + BRANCH="release/${VERSION}" + git checkout -B "$BRANCH" + git add pyproject.toml uv.lock + git diff --staged --quiet && { echo "No version files changed"; exit 1; } + git commit -m "chore(release): ${VERSION}" + git push --force-with-lease origin "HEAD:refs/heads/${BRANCH}" + gh label create release --description "Version bump / PyPI release" --color 0E8A16 --force || true + EXISTING=$(gh pr list --base main --head "$BRANCH" --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "Updated PR #${EXISTING}" + exit 0 + fi + gh pr create --base main --head "$BRANCH" --title "chore(release): ${VERSION}" --label release --body "$(cat <> "$GITHUB_OUTPUT" + echo "publish=true" >> "$GITHUB_OUTPUT" + else + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "pyproject version is already on PyPI; skipping upload" + fi + + publish: + needs: check + if: needs.check.outputs.publish == 'true' + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/nitrostack/ + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m pip install -U build && python -m build + - uses: pypa/gh-action-pypi-publish@release/v1 + - name: Tag release + env: + VERSION: ${{ needs.check.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + TAG="v${VERSION}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag $TAG already exists" + exit 0 + fi + git tag -a "$TAG" -m "nitrostack ${VERSION}" + git push origin "$TAG" diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..4b466ae --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Bump and sync the SDK version in pyproject.toml and uv.lock. + +Version sources of truth: + - pyproject.toml [project].version + - uv.lock [[package]] name = \"nitrostack\" (editable .) + +Template app versions (0.1.0) and README CLI examples are not the SDK release. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.request +from typing import Optional, Sequence, Tuple + +PYPI_JSON = "https://pypi.org/pypi/nitrostack/json" +_PROJECT_VERSION = re.compile(r'(?m)^(version = ")([^"]+)(")') +_LOCK_NITROSTACK = re.compile( + r'(name = "nitrostack"\nversion = ")([^"]+)("\nsource = \{ editable = "\." \})', +) + + +def parse_x_y_z(raw: str) -> Tuple[int, int, int]: + parts = (raw or "").strip().split(".") + if len(parts) != 3 or not all(p.isdigit() for p in parts): + raise ValueError(f"Expected X.Y.Z version, got {raw!r}") + return int(parts[0]), int(parts[1]), int(parts[2]) + + +def bump_x_y_z(raw: str, kind: str) -> str: + major, minor, patch = parse_x_y_z(raw) + if kind == "major": + return f"{major + 1}.0.0" + if kind == "minor": + return f"{major}.{minor + 1}.0" + if kind == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"Unknown bump kind {kind!r}") + + +def version_key(raw: str) -> Tuple[int, int, int]: + return parse_x_y_z(raw) + + +def read_project_version(pyproject_text: str) -> str: + match = _PROJECT_VERSION.search(pyproject_text) + if not match: + raise ValueError("pyproject.toml has no version = \"...\" field") + return match.group(2) + + +def set_project_version(pyproject_text: str, version: str) -> str: + updated, n = _PROJECT_VERSION.subn(rf"\g<1>{version}\3", pyproject_text, count=1) + if n != 1: + raise ValueError("Could not replace [project].version in pyproject.toml") + return updated + + +def set_lock_nitrostack_version(lock_text: str, version: str) -> str: + updated, n = _LOCK_NITROSTACK.subn(rf"\g<1>{version}\3", lock_text, count=1) + if n != 1: + raise ValueError("Could not replace editable nitrostack version in uv.lock") + return updated + + +def fetch_pypi(url: str = PYPI_JSON) -> dict: + with urllib.request.urlopen(url, timeout=30) as response: + return json.load(response) + + +def pypi_latest_and_released(payload: dict) -> Tuple[Optional[str], set]: + info = payload.get("info") or {} + latest = info.get("version") + released = set((payload.get("releases") or {}).keys()) + return latest, released + + +def plan_bump(git_ver: str, latest: Optional[str], released: set, kind: str) -> Optional[str]: + """Return the next version to write, or None if a bump PR is not needed. + + Unpublished git versions are left for the publish workflow (git not on PyPI). + Otherwise bump from max(git, PyPI latest) so a stale git version cannot + collide with a manual upload (e.g. git 0.3.2, PyPI 0.3.5 → 0.3.6). + """ + if git_ver not in released: + return None + base = git_ver + if latest: + base = git_ver if version_key(git_ver) >= version_key(latest) else latest + return bump_x_y_z(base, kind) + + +def should_publish(git_ver: str, released: set) -> bool: + return git_ver not in released + + +def _read(path: str) -> str: + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def _write(path: str, text: str) -> None: + with open(path, "w", encoding="utf-8") as handle: + handle.write(text) + + +def apply_version(root: str, version: str) -> None: + pyproject = f"{root}/pyproject.toml" + lock = f"{root}/uv.lock" + _write(pyproject, set_project_version(_read(pyproject), version)) + _write(lock, set_lock_nitrostack_version(_read(lock), version)) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", default=".") + parser.add_argument("--bump", choices=("patch", "minor", "major"), default="patch") + parser.add_argument( + "--write", + action="store_true", + help="Write pyproject.toml and uv.lock. Prints the new version.", + ) + parser.add_argument( + "--if-unpublished", + action="store_true", + help="Exit 0 if the git version is not on PyPI (publish it); else exit 1.", + ) + args = parser.parse_args(argv) + + git_ver = read_project_version(_read(f"{args.root}/pyproject.toml")) + payload = fetch_pypi() + latest, released = pypi_latest_and_released(payload) + + if args.if_unpublished: + if should_publish(git_ver, released): + print(git_ver) + return 0 + print(f"skip: {git_ver} is already on PyPI", file=sys.stderr) + return 1 + + planned = plan_bump(git_ver, latest, released, args.bump) + if planned is None: + print( + f"skip: {git_ver} is not on PyPI yet (publish this version first)", + file=sys.stderr, + ) + return 0 + if args.write: + apply_version(args.root, planned) + print(planned) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py new file mode 100644 index 0000000..ffa7bb7 --- /dev/null +++ b/tests/test_bump_version.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import importlib.util +import os + +import pytest + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +SCRIPT = os.path.join(ROOT, "scripts", "bump_version.py") + + +def _load(): + spec = importlib.util.spec_from_file_location("bump_version", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +bump = _load() + + +def test_bump_x_y_z(): + assert bump.bump_x_y_z("0.3.5", "patch") == "0.3.6" + assert bump.bump_x_y_z("0.3.5", "minor") == "0.4.0" + assert bump.bump_x_y_z("0.3.5", "major") == "1.0.0" + + +def test_plan_bump_skips_unpublished_git_version(): + assert bump.plan_bump("0.3.6", "0.3.5", {"0.3.5"}, "patch") is None + + +def test_plan_bump_from_pypi_when_git_is_stale(): + released = {"0.3.2", "0.3.4", "0.3.5"} + assert bump.plan_bump("0.3.2", "0.3.5", released, "patch") == "0.3.6" + + +def test_plan_bump_patch_when_git_matches_pypi(): + assert bump.plan_bump("0.3.5", "0.3.5", {"0.3.5"}, "patch") == "0.3.6" + + +def test_should_publish(): + assert bump.should_publish("0.3.6", {"0.3.5"}) is True + assert bump.should_publish("0.3.5", {"0.3.5"}) is False + + +def test_apply_version_syncs_pyproject_and_lock(tmp_path): + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "nitrostack"\nversion = "0.3.2"\n', + encoding="utf-8", + ) + (tmp_path / "uv.lock").write_text( + '[[package]]\nname = "annotated-types"\nversion = "0.8.0"\n\n' + '[[package]]\nname = "nitrostack"\nversion = "0.3.2"\n' + 'source = { editable = "." }\n', + encoding="utf-8", + ) + bump.apply_version(str(tmp_path), "0.3.6") + pyproject = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + lock = (tmp_path / "uv.lock").read_text(encoding="utf-8") + assert 'version = "0.3.6"' in pyproject + assert 'name = "nitrostack"\nversion = "0.3.6"\nsource = { editable = "." }' in lock + assert 'name = "annotated-types"\nversion = "0.8.0"' in lock + + +def test_set_project_version_only_first_field(): + text = '[project]\nversion = "0.3.2"\n\n[tool.ruff]\ntarget-version = "py310"\n' + assert bump.read_project_version(bump.set_project_version(text, "0.3.6")) == "0.3.6"