From 0e145d4bc8c71cefa293a2e643d0a8d42ea3746a Mon Sep 17 00:00:00 2001 From: manish-wekan Date: Fri, 16 Oct 2026 05:55:41 +0530 Subject: [PATCH 1/2] Add scripts/release.sh for releasing to PyPI by hand publish.yml only uploads through PyPI trusted publishing, which has never run here, and the Bump version workflow can't open its release PR (the org doesn't allow GitHub Actions to create pull requests). The script covers both: optionally makes release/ from origin/main (--bump), checks the branch matches origin, the lockfile matches and the version is new, runs the tests, builds, twine-checks, uploads after asking, verifies a clean install from PyPI, and opens the release PR. `--tag` tags origin/main after the merge. `--dry-run` stops before anything leaves the machine. Co-Authored-By: Claude Opus 5.5 --- scripts/release.sh | 235 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 scripts/release.sh diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..c12c846 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# Release the SDK to PyPI from this machine. +# +# publish.yml uploads only through PyPI trusted publishing, and the Bump +# version workflow can't open its release PR (the org doesn't let GitHub +# Actions create pull requests). This script does both by hand. +# +# 1. Get a release branch, either way: +# - the Bump version workflow already pushed `release/X.Y.Z`: +# git switch release/X.Y.Z && git pull +# - or have this script make one from origin/main: +# scripts/release.sh --bump patch (or minor / major) +# 2. On that branch: scripts/release.sh +# checks, tests, builds, uploads to PyPI (asks first), verifies the +# install, and opens the release PR if there isn't one. +# 3. Merge the release PR (publish.yml sees the version on PyPI and skips). +# 4. scripts/release.sh --tag tags origin/main vX.Y.Z and pushes the tag. +# +# Options: +# --bump KIND patch|minor|major: create release/ from origin/main +# --skip-tests don't run the test suite before building +# --yes don't ask before uploading +# --dry-run stop before anything leaves this machine (no push, upload, +# PR or tag); --bump still creates the branch locally +# --tag step 4 only +# +# Needs uv, gh (for the PR) and a PyPI token, in ~/.pypirc ([pypi] with +# username __token__) or in TWINE_USERNAME=__token__ / TWINE_PASSWORD. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +BUMP="" +SKIP_TESTS=0 +YES=0 +DRY_RUN=0 +TAG_ONLY=0 +while [ $# -gt 0 ]; do + case "$1" in + --bump) + BUMP="${2:-}" + shift + ;; + --skip-tests) SKIP_TESTS=1 ;; + --yes) YES=1 ;; + --dry-run) DRY_RUN=1 ;; + --tag) TAG_ONLY=1 ;; + -h | --help) + sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown option: $1 (see --help)" >&2 + exit 2 + ;; + esac + shift +done +case "$BUMP" in "" | patch | minor | major) ;; *) + echo "--bump takes patch, minor or major" >&2 + exit 2 + ;; +esac + +step() { printf '\n==> %s\n' "$*"; } +fail() { + printf '\nerror: %s\n' "$*" >&2 + exit 1 +} + +# uv: a pyenv shim fails inside this repo when the pinned .python-version +# isn't installed in pyenv, so fall back to known install locations. +find_uv() { + local candidate + for candidate in "${UV:-}" "$(command -v uv 2>/dev/null || true)" \ + "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv" /opt/homebrew/bin/uv /usr/local/bin/uv \ + "$HOME"/.pyenv/versions/*/bin/uv; do + if [ -n "$candidate" ] && [ -x "$candidate" ] && "$candidate" --version >/dev/null 2>&1; then + echo "$candidate" + return 0 + fi + done + return 1 +} +UV="$(find_uv)" || fail "uv not found. Install it (https://docs.astral.sh/uv/) or set UV=/path/to/uv." +UVX="$(dirname "$UV")/uvx" +[ -x "$UVX" ] || UVX="$UV tool run" +PY=("$UV" run --no-project --quiet python) + +project_version() { "${PY[@]}" -c 'import re,sys; print(re.search(r"(?m)^version = \"([^\"]+)\"", sys.stdin.read()).group(1))'; } +# Exit 0 when VERSION is on PyPI. +on_pypi() { curl -fs -o /dev/null "https://pypi.org/pypi/nitrostack/$1/json"; } +tag_exists() { git rev-parse -q --verify "refs/tags/v$1" >/dev/null || [ -n "$(git ls-remote --tags origin "refs/tags/v$1")" ]; } + +step "Fetching origin" +git fetch -q --tags origin + +# --- Step 4: tag the merged release ------------------------------------- +if [ "$TAG_ONLY" = 1 ]; then + VERSION="$(git show origin/main:pyproject.toml | project_version)" + echo "origin/main is at $VERSION" + on_pypi "$VERSION" || fail "$VERSION isn't on PyPI. Release it first (scripts/release.sh on its release branch)." + tag_exists "$VERSION" && fail "Tag v$VERSION already exists." + if [ "$DRY_RUN" = 1 ]; then + echo "Dry run: would tag origin/main ($(git rev-parse --short origin/main)) as v$VERSION and push it." + exit 0 + fi + git tag -a "v$VERSION" origin/main -m "nitrostack $VERSION" + git push -q origin "v$VERSION" + echo "Tagged and pushed v$VERSION." + exit 0 +fi + +[ -z "$(git status --porcelain --untracked-files=no)" ] || fail "Uncommitted changes. Commit or stash them first." + +# --- Step 1 (optional): make the release branch ------------------------- +if [ -n "$BUMP" ]; then + step "Creating a $BUMP release branch from origin/main" + MAIN_VERSION="$(git show origin/main:pyproject.toml | project_version)" + on_pypi "$MAIN_VERSION" || fail "origin/main's version $MAIN_VERSION isn't on PyPI yet. Release it as is (no --bump)." + # bump_version plans from the checked-out tree, so check out origin/main; + # go back to where we were if anything below fails. + ORIG_REF="$(git branch --show-current)" + ORIG_REF="${ORIG_REF:-$(git rev-parse HEAD)}" + trap 'git switch -q "$ORIG_REF" 2>/dev/null || git switch -q --detach "$ORIG_REF"' ERR + git switch -q --detach origin/main + NEXT="$("${PY[@]}" scripts/bump_version.py --root . --bump "$BUMP" | tail -n 1)" + [ -n "$NEXT" ] || { + git switch -q "$ORIG_REF" 2>/dev/null || git switch -q --detach "$ORIG_REF" + fail "PyPI's project index doesn't list $MAIN_VERSION yet (it's cached for up to 15 minutes after a release). Try again shortly." + } + git rev-parse -q --verify "refs/heads/release/$NEXT" >/dev/null && fail "Branch release/$NEXT already exists locally." + [ -z "$(git ls-remote --heads origin "release/$NEXT")" ] || fail "release/$NEXT already exists on origin. Use it: git switch release/$NEXT" + git switch -q -c "release/$NEXT" + "${PY[@]}" scripts/bump_version.py --root . --bump "$BUMP" --write >/dev/null + git add pyproject.toml uv.lock + git commit -q -m "chore(release): $NEXT" + trap - ERR + echo "Created release/$NEXT with the version bump." + if [ "$DRY_RUN" = 0 ]; then + git push -q -u origin "release/$NEXT" + fi +fi + +# --- Step 2: check, test, build, upload --------------------------------- +BRANCH="$(git branch --show-current)" +[ -n "$BRANCH" ] || fail "Not on a branch. Switch to the release branch first." +VERSION="$(project_version /dev/null; then + installed="$("$VENV/bin/python" -c 'import importlib.metadata as m, nitrostack; print(m.version("nitrostack"))')" + break + fi + sleep 10 +done +rm -rf "$(dirname "$VENV")" +[ "$installed" = "$VERSION" ] || fail "Couldn't install and import nitrostack==$VERSION from PyPI." +echo "OK: pip install nitrostack==$VERSION works and imports." + +# --- Release PR --------------------------------------------------------- +step "Release PR" +if ! command -v gh >/dev/null; then + echo "gh not found. Open a PR from $BRANCH to main, labelled 'release'." +else + PR="$(gh pr list --base main --head "$BRANCH" --json url --jq '.[0].url // empty')" + if [ -z "$PR" ]; then + PR="$(gh pr create --base main --head "$BRANCH" --title "chore(release): $VERSION" --label release --body "$( + cat < Date: Fri, 16 Oct 2026 06:00:10 +0530 Subject: [PATCH 2/2] release.sh: review fixes - Run from a temp copy: --bump switches branches, which can rewrite the script while bash is still reading it. - --bump plans from origin/main's files in a scratch folder and checks everything before switching, so a failed check no longer leaves you on a detached origin/main. - Only a 404 counts as "not on PyPI"; offline or PyPI errors stop the run instead of passing the "version is new" check. - No terminal and no --yes means "no" instead of a silent exit. - Tag the uploaded commit right after the upload (no separate step that could tag later, unreleased merges on main). --tag stays for catching up untagged releases and prefers the release branch head. - A failed PR creation after the upload prints the manual step. Co-Authored-By: Claude Opus 5.5 --- scripts/release.sh | 82 +++++++++++++++++++++++++++++++--------------- 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index c12c846..8042f2f 100644 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -12,9 +12,8 @@ # scripts/release.sh --bump patch (or minor / major) # 2. On that branch: scripts/release.sh # checks, tests, builds, uploads to PyPI (asks first), verifies the -# install, and opens the release PR if there isn't one. +# install, tags the uploaded commit vX.Y.Z, and opens the release PR. # 3. Merge the release PR (publish.yml sees the version on PyPI and skips). -# 4. scripts/release.sh --tag tags origin/main vX.Y.Z and pushes the tag. # # Options: # --bump KIND patch|minor|major: create release/ from origin/main @@ -22,13 +21,24 @@ # --yes don't ask before uploading # --dry-run stop before anything leaves this machine (no push, upload, # PR or tag); --bump still creates the branch locally -# --tag step 4 only +# --tag only tag a version that's already on PyPI but untagged +# (the head of origin/release/X.Y.Z if it still exists, +# else origin/main) # # Needs uv, gh (for the PR) and a PyPI token, in ~/.pypirc ([pypi] with # username __token__) or in TWINE_USERNAME=__token__ / TWINE_PASSWORD. set -euo pipefail -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# bash reads a script while running it, and --bump switches branches, which +# can rewrite this file. Run from a copy. +if [ -z "${NITRO_RELEASE_ROOT:-}" ]; then + NITRO_RELEASE_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + copy="$(mktemp)" + cp "${BASH_SOURCE[0]}" "$copy" + NITRO_RELEASE_ROOT="$NITRO_RELEASE_ROOT" NITRO_RELEASE_COPY="$copy" exec bash "$copy" "$@" +fi +trap 'rm -f "${NITRO_RELEASE_COPY:-}"' EXIT +ROOT="$NITRO_RELEASE_ROOT" cd "$ROOT" BUMP="" @@ -47,7 +57,7 @@ while [ $# -gt 0 ]; do --dry-run) DRY_RUN=1 ;; --tag) TAG_ONLY=1 ;; -h | --help) - sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' exit 0 ;; *) @@ -89,8 +99,17 @@ UVX="$(dirname "$UV")/uvx" PY=("$UV" run --no-project --quiet python) project_version() { "${PY[@]}" -c 'import re,sys; print(re.search(r"(?m)^version = \"([^\"]+)\"", sys.stdin.read()).group(1))'; } -# Exit 0 when VERSION is on PyPI. -on_pypi() { curl -fs -o /dev/null "https://pypi.org/pypi/nitrostack/$1/json"; } +# Exit 0 when VERSION is on PyPI, 1 when it isn't; stop on any other answer +# (offline, PyPI down) rather than guess. +on_pypi() { + local code + code="$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/nitrostack/$1/json" || true)" + case "$code" in + 200) return 0 ;; + 404) return 1 ;; + *) fail "Couldn't check PyPI for $1 (HTTP ${code:-none}). Check your connection and try again." ;; + esac +} tag_exists() { git rev-parse -q --verify "refs/tags/v$1" >/dev/null || [ -n "$(git ls-remote --tags origin "refs/tags/v$1")" ]; } step "Fetching origin" @@ -102,11 +121,17 @@ if [ "$TAG_ONLY" = 1 ]; then echo "origin/main is at $VERSION" on_pypi "$VERSION" || fail "$VERSION isn't on PyPI. Release it first (scripts/release.sh on its release branch)." tag_exists "$VERSION" && fail "Tag v$VERSION already exists." + # Tag what was uploaded: the release branch head, when it's still around. + TARGET=origin/main + if git rev-parse -q --verify "refs/remotes/origin/release/$VERSION" >/dev/null && + [ "$(git show "origin/release/$VERSION:pyproject.toml" | project_version)" = "$VERSION" ]; then + TARGET="origin/release/$VERSION" + fi if [ "$DRY_RUN" = 1 ]; then - echo "Dry run: would tag origin/main ($(git rev-parse --short origin/main)) as v$VERSION and push it." + echo "Dry run: would tag $TARGET ($(git rev-parse --short "$TARGET")) as v$VERSION and push it." exit 0 fi - git tag -a "v$VERSION" origin/main -m "nitrostack $VERSION" + git tag -a "v$VERSION" "$TARGET" -m "nitrostack $VERSION" git push -q origin "v$VERSION" echo "Tagged and pushed v$VERSION." exit 0 @@ -119,24 +144,23 @@ if [ -n "$BUMP" ]; then step "Creating a $BUMP release branch from origin/main" MAIN_VERSION="$(git show origin/main:pyproject.toml | project_version)" on_pypi "$MAIN_VERSION" || fail "origin/main's version $MAIN_VERSION isn't on PyPI yet. Release it as is (no --bump)." - # bump_version plans from the checked-out tree, so check out origin/main; - # go back to where we were if anything below fails. - ORIG_REF="$(git branch --show-current)" - ORIG_REF="${ORIG_REF:-$(git rev-parse HEAD)}" - trap 'git switch -q "$ORIG_REF" 2>/dev/null || git switch -q --detach "$ORIG_REF"' ERR - git switch -q --detach origin/main - NEXT="$("${PY[@]}" scripts/bump_version.py --root . --bump "$BUMP" | tail -n 1)" - [ -n "$NEXT" ] || { - git switch -q "$ORIG_REF" 2>/dev/null || git switch -q --detach "$ORIG_REF" - fail "PyPI's project index doesn't list $MAIN_VERSION yet (it's cached for up to 15 minutes after a release). Try again shortly." - } + # Plan from origin/main's files (and its bump_version.py) in a scratch + # folder, so nothing changes here until every check has passed. + PLAN="$(mktemp -d)" + for f in pyproject.toml uv.lock scripts/bump_version.py; do + mkdir -p "$PLAN/$(dirname "$f")" + git show "origin/main:$f" >"$PLAN/$f" + done + NEXT="$("${PY[@]}" "$PLAN/scripts/bump_version.py" --root "$PLAN" --bump "$BUMP" | tail -n 1)" + [ -n "$NEXT" ] || fail "PyPI's project index doesn't list $MAIN_VERSION yet (it's cached for up to 15 minutes after a release). Try again shortly." git rev-parse -q --verify "refs/heads/release/$NEXT" >/dev/null && fail "Branch release/$NEXT already exists locally." [ -z "$(git ls-remote --heads origin "release/$NEXT")" ] || fail "release/$NEXT already exists on origin. Use it: git switch release/$NEXT" - git switch -q -c "release/$NEXT" - "${PY[@]}" scripts/bump_version.py --root . --bump "$BUMP" --write >/dev/null + "${PY[@]}" "$PLAN/scripts/bump_version.py" --root "$PLAN" --bump "$BUMP" --write >/dev/null + git switch -q -c "release/$NEXT" origin/main + cp "$PLAN/pyproject.toml" "$PLAN/uv.lock" . + rm -rf "$PLAN" git add pyproject.toml uv.lock git commit -q -m "chore(release): $NEXT" - trap - ERR echo "Created release/$NEXT with the version bump." if [ "$DRY_RUN" = 0 ]; then git push -q -u origin "release/$NEXT" @@ -186,11 +210,15 @@ fi step "Uploading nitrostack $VERSION to PyPI" if [ "$YES" = 0 ]; then - read -r -p "Upload nitrostack $VERSION to PyPI? A version can't be uploaded twice. [y/N] " answer + read -r -p "Upload nitrostack $VERSION to PyPI? A version can't be uploaded twice. [y/N] " answer || answer="" case "$answer" in y | Y | yes) ;; *) fail "Not uploaded." ;; esac fi $UVX twine upload --non-interactive "$WHEEL" "$SDIST" +# Tag the exact commit that was built and uploaded. +git tag -a "v$VERSION" HEAD -m "nitrostack $VERSION" +git push -q origin "v$VERSION" || echo "warning: couldn't push tag v$VERSION. Push it yourself: git push origin v$VERSION" >&2 + step "Checking the release on PyPI" for _ in $(seq 1 30); do on_pypi "$VERSION" && break @@ -224,12 +252,12 @@ else - Bump SDK version to **$VERSION** in \`pyproject.toml\` and \`uv.lock\`. - $VERSION is already on PyPI (uploaded by \`scripts/release.sh\`), so \`publish.yml\` will skip the upload when this merges. -After merging, tag it: \`scripts/release.sh --tag\` +Tagged \`v$VERSION\` on the uploaded commit. EOF - )")" + )")" || PR="Couldn't open the PR. Open one from $BRANCH to main, labelled 'release'." fi echo "$PR" fi echo -echo "Released nitrostack $VERSION. Next: merge the release PR, then run scripts/release.sh --tag" +echo "Released nitrostack $VERSION (tag v$VERSION). Next: merge the release PR."