diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b253426..309e895 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,28 +1,19 @@ # genlayer-testing-suite -## Releases — CRITICAL +## Releases -This repo uses `python-semantic-release` (config: `releaserc.toml`, CI: `.github/workflows/publish.yml`). +This repo uses a branch-per-major release model. There is no `main`. Releases are deliberate, not automatic. -**NEVER do any of the following:** -- Manually edit version strings in `pyproject.toml` or `glsim/__init__.py` -- Manually create git tags (`git tag vX.Y.Z`) -- Manually run `semantic-release` locally -- Commit messages like `chore: bump version to X.Y.Z` +See `.claude/skills/release/SKILL.md` for the full release flow. Short version: -**How releases work:** -1. Push conventional commits to `main` (`feat:` → minor, `fix:` → patch) -2. CI runs `semantic-release version` which auto-bumps versions, creates commit + tag, pushes -3. CI builds and publishes to PyPI via twine +- Branches are per-major: `v0.29` (current stable), `v-dev` when next-major work is in progress. +- Releases go through `scripts/release.sh` on the target branch. The script bumps `pyproject.toml` + `glsim/__init__.py`, updates `CHANGELOG.md` via python-semantic-release, commits, tags `vX.Y.Z`, and pushes. +- `publish.yml` fires on the tag push and ships to PyPI. +- **Semver-zero rule**: this package is on 0.x, so minor IS the breaking-change boundary. `0.29 → 0.30` is a major bump and needs a new branch — `scripts/release.sh` refuses `minor`/`major` keywords without `--allow-major`. **When user says "release":** -- Verify all changes are committed and pushed to `main` with proper conventional commit prefixes -- That's it. CI handles the rest. Do NOT touch versions or tags. -- If CI fails, inspect the workflow logs (`gh run view`) — don't try to manually publish - -**Version files managed by semantic-release:** -- `pyproject.toml:project.version` -- `glsim/__init__.py:__version__` +- Invoke the release skill. It will confirm version + branch, run pre-flight checks, then call `scripts/release.sh`. +- If CI on the tag fails, inspect the workflow logs (`gh run view`) — fix the issue, delete the bad tag, re-run the script. ## Architecture @@ -33,7 +24,7 @@ This repo uses `python-semantic-release` (config: `releaserc.toml`, CI: `.github ## Conventional Commits -All commits must use conventional format: -- `feat(scope): description` — new feature (minor bump) -- `fix(scope): description` — bug fix (patch bump) -- `chore/docs/refactor/test: description` — no version bump +Commits should still use conventional format because the release script generates the changelog from them: +- `feat(scope): description` — new feature +- `fix(scope): description` — bug fix +- `chore/docs/refactor/test: description` — no changelog entry diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..d89b967 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,94 @@ +--- +name: release +description: Cut a release of genlayer-testing-suite. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to PyPI and creates the GitHub Release. Use when a human asks "release v0.29.x" or "ship a new version". +--- + +# Release skill — genlayer-testing-suite + +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.29.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.29` (current stable). When `v0.19` opens, the previous `v0.29` stays read-only for back-ports. +- Tags live within those branches: `v0.29.1`, `v0.29.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.29 + git pull --ff-only origin v0.29 + ``` + 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.29 --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-test + ``` + 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 c937eaa..3724609 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,111 +1,77 @@ -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 (and glsim/__init__.py), 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 + runs-on: ubuntu-latest 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 }} - 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" - - - name: Build | Clean Previous Builds - if: steps.release.outputs.released == 'true' - run: | - rm -rf -- dist build *.egg-info - - - name: Build | Create Distribution Package - if: steps.release.outputs.released == 'true' + 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: Clean previous builds + run: rm -rf -- dist build *.egg-info + + - 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 - run: uv publish dist/* + - name: Publish to PyPI + run: | + if [ -z "${{ secrets.PYPI_API_TOKEN }}" ]; then + echo "Missing PyPI API token"; exit 1; + fi + 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 6c108a9..c265477 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,28 @@ Have ideas for new features or use cases? We're eager to hear them! But first: +## Branch model + +This repo uses a branch-per-major release model. There is no `main`. + +- **`v0.29`** — current stable major (semver-zero, so 0.29 IS the major; 0.30 would be a major bump that gets its own branch). +- **`v-dev`** — when next-major work is in progress. +- Default branch on github.com is the current stable. + +If you have a `main` branch from a previous checkout: + +```sh +git checkout v0.29 +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 0.x, so minor IS the breaking-change boundary. `0.29 → 0.30` 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..8abd0a2 --- /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.29 for v0.29.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 <