From 8314002b7bd510927940527fe08f15987186a486 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 21 May 2026 09:30:41 +0100 Subject: [PATCH] feat: branch-per-major release model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors genlayer-js#172 — branches per major (v0.18 today), no main, releases via scripts/release.sh + Claude skill, publish.yml only fires on tag push. What changes: - publish.yml: trigger `push: main` → `push: tags v*`. The workflow no longer runs python-semantic-release in CI; it sanity-checks tag == pyproject.toml version, builds, publishes to PyPI, cuts the GitHub Release. - scripts/release.sh: new release entry point. Refuses minor/major bumps without --allow-major because we're on 0.x (semver-zero: minor is the breaking-change boundary). Uses python-semantic-release with an explicit version + --no-push --no-vcs-release so it just bumps, changelogs, commits, and tags locally; the script pushes branch + tag explicitly. - .claude/skills/release/SKILL.md: documents the flow for Claude. - .github/e2e-track: main → v0.5 (runner's current stable matrix track). - CONTRIBUTING.md: branch model + semver-zero note + release pointer. Follow-up (separate PRs): - Switch default branch to v0.18, delete origin/main. - Same shape for genlayer-cli, genlayer-testing-suite, genlayer-explorer. - Runner matrix v0.5.yaml: genlayer-py pin switches from v0.18.0 tag to v0.18 branch. --- .claude/skills/release/SKILL.md | 94 +++++++++++++++++ .github/e2e-track | 1 + .github/workflows/publish.yml | 131 +++++++++--------------- CONTRIBUTING.md | 22 ++++ scripts/release.sh | 174 ++++++++++++++++++++++++++++++++ 5 files changed, 337 insertions(+), 85 deletions(-) create mode 100644 .claude/skills/release/SKILL.md create mode 100644 .github/e2e-track create mode 100755 scripts/release.sh diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..7221bc0 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,94 @@ +--- +name: release +description: Cut a release of genlayer-py. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. Use when a human asks "release v0.18.x" or "ship a new version". +--- + +# Release skill — genlayer-py + +This repo follows a branch-per-major release model. There is no auto-bump on push. A release happens when a human (or you on their behalf) runs `scripts/release.sh` on the target stable branch. + +## When to use this skill + +User asks anything like: +- "release v0.18.1" +- "ship a patch" +- "tag the latest fix as a release" + +If they ask "publish to PyPI directly" — refuse and point at this flow. The repo doesn't have an unprotected PyPI push path; the tag is the only release entry point. + +## What this repo's release model expects + +- Branches are named after the major they ship: `v0.18` (current stable). When `v0.19` opens, the previous `v0.18` stays read-only for back-ports. +- Tags live within those branches: `v0.18.1`, `v0.18.2`, ... +- **Semver-zero rule**: this package is still on a 0.x line, so the MINOR component is the breaking-change boundary. `0.18 → 0.19` IS a major bump. `scripts/release.sh` refuses both `minor` and `major` keywords without `--allow-major` while we're on 0.x. +- A major (= minor on 0.x) bump means cutting a new branch (`v0.19`) — not tagging on top of the current one. +- `CHANGELOG.md` is updated in the release commit (python-semantic-release with explicit version). +- `publish.yml` fires on the tag push and does the PyPI publish + GitHub Release. + +## Steps + +1. **Confirm intent with the user.** + - Which version? If unspecified, ask whether it's patch or explicit. + - If they say "minor" or "major" while we're on 0.x, surface that this means cutting a new branch — confirm before proceeding. + +2. **Switch to the target branch + sync.** + ```bash + git checkout v0.18 + git pull --ff-only origin v0.18 + ``` + If the working tree isn't clean, stop and surface what's there. + +3. **Verify the head is shippable.** + - Latest CI green: + ```bash + gh run list --branch v0.18 --commit "$(git rev-parse HEAD)" --limit 1 + ``` + - Inspect commits since the previous tag for surprises: + ```bash + git log "$(git describe --tags --abbrev=0)..HEAD" --oneline + ``` + +4. **Run the release script.** + ```bash + scripts/release.sh # or patch + ``` + It bumps `pyproject.toml`, updates `CHANGELOG.md`, commits `chore(release): vX.Y.Z`, tags `vX.Y.Z`, and pushes both the branch commit and the tag. It will NOT publish to PyPI — CI handles that. + +5. **Watch the publish workflow.** + ```bash + gh run watch + ``` + If `publish.yml` fails (typical: tag/pyproject mismatch, expired `PYPI_API_TOKEN`, build failure), report verbatim and stop. Don't retry blindly. + +6. **Confirm on PyPI.** + ```bash + pip index versions genlayer-py + ``` + The latest version should match. Report back with the version and the GitHub Release URL. + +## Things to refuse + +- **Minor or major bump on 0.x without `--allow-major`**. Those are major bumps in semver-zero and belong on a new branch. +- **Releasing from `main`** — `main` is retired. +- **Hand-editing `pyproject.toml` to bump the version** — the script keeps pyproject, the CHANGELOG entry, the commit message, and the tag in lockstep. +- **Publishing a tag where `publish.yml` failed** — fix the underlying issue, re-cut the release (delete the bad tag locally and on origin, re-run the script). + +## Roll-back + +If a release shipped but is broken: + +1. **Don't yank from PyPI** unless someone with elevated permissions has assessed the impact — PyPI yank is reversible but signals "skip this" to installers and you'll want a follow-up patch up first. +2. **Ship a follow-up patch** via the same flow (`scripts/release.sh patch`). +3. After the fixed version is live, optionally yank the bad version: + ```bash + pip install pkginfo twine + # use pypi.org web UI to yank — there's no CLI in current PyPI flow + ``` + +## Why no auto-bump? + +Previously `push: main` triggered `python-semantic-release`, which would auto-bump and tag whenever a `feat:`/`fix:` commit landed. Two failure modes that fix-on-merge can't address: +- Conflated decisions — "merge this PR" silently meant "ship to PyPI". +- Major bumps that slip through (`BREAKING CHANGE` in a PR body produces a 0.X → 0.X+1 bump while on 0.x, which is a major). + +Manual + scripted puts a checkpoint between the two without losing the bump-tag automation. diff --git a/.github/e2e-track b/.github/e2e-track new file mode 100644 index 0000000..83b4ac5 --- /dev/null +++ b/.github/e2e-track @@ -0,0 +1 @@ +v0.5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 48b8dbd..c33afa3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,111 +1,56 @@ -name: Continuous Delivery - +name: Publish Package to PyPI + +# Tag-driven publish. The release is cut by a human (or Claude via the +# release skill) running scripts/release.sh on the target stable branch +# — that script bumps pyproject.toml, updates CHANGELOG.md, commits, +# tags vX.Y.Z, and pushes both the branch commit and the tag. This +# workflow fires on the tag push, runs tests, sanity-checks the tag +# matches pyproject.toml, builds, and publishes to PyPI. It never +# bumps or tags by itself. on: workflow_dispatch: push: - branches: - - main + tags: + - "v*" jobs: run-tests: name: Run Tests uses: ./.github/workflows/tests.yml - release-and-upload: - name: Release and Upload Artifacts - runs-on: ubuntu-latest + publish-to-pypi: + name: Publish Package to PyPI needs: run-tests - environment: Publish - - concurrency: - group: ${{ github.workflow }}-release-${{ github.ref_name }} - cancel-in-progress: false - - permissions: - contents: write - - outputs: - released: ${{ steps.release.outputs.released }} - commit_sha: ${{ steps.get-commit.outputs.commit_sha }} + runs-on: ubuntu-latest steps: - - name: Setup | Get CI Bot Token - uses: actions/create-github-app-token@v3 - id: ci_bot_token - with: - client-id: ${{ vars.PUBLISH_CI_APP_CLIENT_ID }} - private-key: ${{ secrets.PUBLISH_CI_APP_KEY }} - - - name: Setup | Checkout Repository + - name: Checkout tag uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - token: ${{ steps.ci_bot_token.outputs.token }} - - - name: Check | Verify Upstream Unchanged - shell: bash - run: | - chmod +x scripts/verify-upstream.sh - ./scripts/verify-upstream.sh ${{ github.sha }} - - name: Setup | Initialize Git User - run: | - git config --global user.email "github-actions[bot]@genlayerlabs.com" - git config --global user.name "github-actions[bot]" - - - name: Setup | Install uv + - name: Install uv uses: astral-sh/setup-uv@v5 - - name: Setup | Install Python + - name: Install Python run: uv python install 3.12 - - name: Action | Semantic Version Release - id: release - env: - GH_TOKEN: ${{ steps.ci_bot_token.outputs.token }} + - name: Verify tag matches pyproject.toml version run: | - chmod +x scripts/semantic-version-release.sh - ./scripts/semantic-version-release.sh releaserc.toml - - - name: Get | Current Commit SHA - id: get-commit - run: echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + TAG_VERSION="${GITHUB_REF_NAME#v}" + PKG_VERSION="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag ($TAG_VERSION) and pyproject.toml ($PKG_VERSION) disagree — refusing to publish." >&2 + echo "Re-cut the release via scripts/release.sh so the tag and the committed version match." >&2 + exit 1 + fi + echo "Tag $GITHUB_REF_NAME matches pyproject.toml $PKG_VERSION." - - name: Build | Clean Previous Builds - if: steps.release.outputs.released == 'true' - run: | - rm -rf -- dist build *.egg-info + - name: Clean previous builds + run: rm -rf -- dist build *.egg-info - - name: Build | Create Distribution Package - if: steps.release.outputs.released == 'true' + - name: Build distribution run: uv build - - name: Upload | Distribution Artifacts - if: steps.release.outputs.released == 'true' - uses: actions/upload-artifact@v4 - with: - name: distribution-artifacts - path: dist - if-no-files-found: error - - publish-to-pypi: - name: Publish Package to PyPI - needs: release-and-upload - runs-on: ubuntu-latest - if: ${{ needs.release-and-upload.outputs.released == 'true' }} - - steps: - - name: Setup | Install uv - uses: astral-sh/setup-uv@v5 - - - name: Download | Distribution Artifacts - uses: actions/download-artifact@v4 - with: - name: distribution-artifacts - path: dist - - - name: Publish | Upload to PyPI + - name: Publish to PyPI run: | if [ -z "${{ secrets.PYPI_API_TOKEN }}" ]; then echo "Missing PyPI API token"; exit 1; @@ -113,3 +58,19 @@ jobs: uv publish dist/* env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NOTES="$(awk -v ver="$GITHUB_REF_NAME" ' + $0 ~ "^## \\[?" substr(ver, 2) {capture=1; next} + capture && /^## / {exit} + capture {print} + ' CHANGELOG.md)" + if [ -z "$NOTES" ]; then + NOTES="Release $GITHUB_REF_NAME" + fi + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --notes "$NOTES" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cbeab2..17b6afe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,28 @@ Have ideas for new features or use cases? We're eager to hear them! But first: - Ensure you have the SDK installed to explore existing use cases. - After familiarizing yourself with the SDK, contribute your unique use case and share your ideas in our [Discord channel](https://discord.gg/8Jm4v89VAu). +## Branch model + +This repo uses a branch-per-major release model. There is no `main`. + +- **`v0.18`** — current stable major (semver-zero, so 0.18 IS the major; 0.19 would be a major bump that gets its own branch). PRs for bug fixes / non-breaking features target this branch. +- **`v-dev`** — when next-major (i.e. next-minor on 0.x) work is in progress, this branch is open for breaking changes. PRs introducing them target this branch, not `v0.18`. +- **Older majors** stay on the repo for back-ports and security patches. Default branch on github.com is whichever major is current stable. + +When you fork or clone, the default branch is `v0.18` today. If you have a `main` branch from a previous checkout, delete it locally: + +```sh +git checkout v0.18 +git branch -D main +git remote prune origin +``` + +## Releases + +Releases are deliberate, not automatic. `scripts/release.sh` bumps the version, updates `CHANGELOG.md`, commits, tags, and pushes; CI takes over from the tag push and publishes to PyPI. See `.claude/skills/release/SKILL.md` for the full flow. + +**Semver-zero rule**: this package is on a 0.x line, so the MINOR component is the breaking-change boundary. `0.18 → 0.19` is a major bump and needs a new branch — the script refuses `minor`/`major` keywords without `--allow-major`. + ### Bug fixing and Feature development #### 1. Set yourself up to start coding diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..becd986 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +# Cut a release on the current stable branch. +# +# Bumps pyproject.toml, updates CHANGELOG.md via python-semantic-release, +# commits, tags vX.Y.Z, and pushes both the branch commit and the tag. +# publish.yml takes over from the tag push (build → PyPI publish → +# GitHub Release). +# +# Releases are deliberate. There is no auto-bump on push; only this +# script is supposed to create release tags. Run from the major branch +# you want to ship a release on (e.g. v0.18 for v0.18.x). +# +# Usage: +# scripts/release.sh # explicit semver — recommended +# scripts/release.sh patch # 0.18.0 → 0.18.1 +# scripts/release.sh minor # 0.18.0 → 0.19.0 — refused unless --allow-major (see below) +# scripts/release.sh major # 0.18.0 → 1.0.0 — refused unless --allow-major +# scripts/release.sh --allow-major +# +# Semver-zero rule: while the major is 0, the MINOR is the breaking- +# change boundary (per semver). 0.18 → 0.19 IS a major bump. The script +# refuses both `minor` and `major` keywords without --allow-major while +# the current major is 0. Patches stay automatic-friendly. +# +# Pre-flight (each check refuses to proceed on failure): +# - On a v[.] branch (refuses on main / feature branches) +# - Working tree clean +# - Local HEAD matches origin/ +# - Latest CI run on HEAD is green + +set -euo pipefail + +ALLOW_MAJOR=0 +if [ "${1:-}" = "--allow-major" ]; then + ALLOW_MAJOR=1 + shift +fi + +VERSION_ARG="${1:-}" +if [ -z "$VERSION_ARG" ]; then + echo "Usage: $0 [--allow-major] |patch|minor|major" >&2 + exit 2 +fi + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +branch="$(git rev-parse --abbrev-ref HEAD)" +if ! [[ "$branch" =~ ^v[0-9]+(\.[0-9]+)?(-dev)?$ ]]; then + cat >&2 <&2 + exit 1 +fi + +git fetch --tags origin "$branch" +local_sha="$(git rev-parse HEAD)" +remote_sha="$(git rev-parse "origin/$branch")" +if [ "$local_sha" != "$remote_sha" ]; then + cat >&2 </dev/null 2>&1; then + status="$(gh run list --branch "$branch" --commit "$local_sha" --limit 1 --json conclusion --jq '.[0].conclusion' 2>/dev/null || echo "")" + case "$status" in + success) ;; + "" ) + echo "Warning: no CI run found for $local_sha on $branch. Continuing anyway." >&2 + ;; + *) + echo "Latest CI on $branch@$local_sha is '$status' (not success). Refusing to release a red commit." >&2 + exit 1 + ;; + esac +fi + +current_version="$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "([^"]+)"/\1/')" + +# Resolve to a concrete X.Y.Z so the major-bump guard can compare. +case "$VERSION_ARG" in + major|minor|patch) + next_version="$(python3 - "$current_version" "$VERSION_ARG" <<'PY' +import sys +cur = sys.argv[1].split(".") +kind = sys.argv[2] +major, minor, patch = int(cur[0]), int(cur[1]), int(cur[2]) +if kind == "major": + print(f"{major+1}.0.0") +elif kind == "minor": + print(f"{major}.{minor+1}.0") +elif kind == "patch": + print(f"{major}.{minor}.{patch+1}") +PY +)" + ;; + *) + next_version="$VERSION_ARG" + ;; +esac + +if ! [[ "$next_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Not a valid semver: $next_version" >&2 + exit 2 +fi + +cur_major="${current_version%%.*}" +next_major="${next_version%%.*}" +cur_minor="$(echo "$current_version" | cut -d. -f2)" +next_minor="$(echo "$next_version" | cut -d. -f2)" + +# Semver-zero: while major == 0, MINOR bumps are major bumps. +if [ "$cur_major" = "0" ]; then + if [ "$next_major" != "0" ] || [ "$next_minor" != "$cur_minor" ]; then + if [ "$ALLOW_MAJOR" -ne 1 ]; then + cat >&2 <&2 <