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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .github/workflows/bump-version.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
## Summary
- Bump SDK version to **${VERSION}** in \`pyproject.toml\` and \`uv.lock\`.
- Merge this PR to publish \`${VERSION}\` to PyPI (see \`publish.yml\`).

## Test plan
- [ ] Version in \`pyproject.toml\` is ${VERSION} and is not already on PyPI
- [ ] \`uv.lock\` editable \`nitrostack\` package version matches
EOF
)"
64 changes: 64 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Publish to PyPI

# Runs when main moves. Uploads only if [project].version is not already on PyPI
# (the release PR merge). Feature merges no-op in the check job so a protected
# `pypi` environment does not block ordinary merges.
on:
push:
branches: [main]

jobs:
check:
runs-on: ubuntu-latest
outputs:
publish: ${{ steps.check.outputs.publish }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- id: check
run: |
set -euo pipefail
if VERSION=$(python scripts/bump_version.py --if-unpublished); then
echo "version=$VERSION" >> "$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"
159 changes: 159 additions & 0 deletions scripts/bump_version.py
Original file line number Diff line number Diff line change
@@ -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())
68 changes: 68 additions & 0 deletions tests/test_bump_version.py
Original file line number Diff line number Diff line change
@@ -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"
Loading