diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..dc916d8 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,99 @@ +--- +name: release +description: Cut a release of genlayer-js. Bumps version, updates CHANGELOG, tags, pushes — CI then publishes to npm and creates the GitHub Release. Use when a human asks "release v1.x.y" or "ship a new version". +--- + +# Release skill — genlayer-js + +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 v1.2.0" +- "ship a patch" +- "cut a new minor" +- "tag the latest fix as a release" + +If they ask "publish to npm directly" — refuse and point at this flow. The repo doesn't have an unprotected npm 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: `v1` (current stable), `v2-dev` / `v2` (next major when it exists). +- Tags live within those branches: `v1.1.9`, `v1.2.0`, ... +- A major bump means **cutting a new branch**, not tagging on the current one. The release script refuses major bumps unless `--allow-major` is passed. +- `CHANGELOG.md` is updated in the release commit (release-it via `@release-it/conventional-changelog`). +- `publish.yml` fires on the tag push and does the npm publish + GitHub Release. + +## Steps + +1. **Confirm intent with the user.** + - Which version? If unspecified, ask whether it's patch / minor / explicit. + - Which branch? Default `v1`. If they're shipping a back-port to an older major, the branch is `v`. + +2. **Switch to the target branch + sync.** + ```bash + git checkout v1 + git pull --ff-only origin v1 + ``` + If the working tree isn't clean, stop and surface what's there — never stash and ship. + +3. **Verify the head is shippable.** + - Latest CI run on this commit is green (the release script also checks, but check first so you don't half-run the script): + ```bash + gh run list --branch v1 --commit "$(git rev-parse HEAD)" --limit 1 + ``` + - Inspect the last few commits since the previous tag for surprises: + ```bash + git log "$(git describe --tags --abbrev=0)..HEAD" --oneline + ``` + - If anything looks unexpected (e.g. an in-flight refactor accidentally landed), surface it and wait for the user's call. + +4. **Run the release script.** + ```bash + scripts/release.sh # or patch / minor + ``` + It will: bump `package.json`, prepend `CHANGELOG.md`, commit `Release v [skip ci]`, tag `v`, and push both the branch commit and the tag. It will NOT publish to npm — CI handles that. + +5. **Watch the publish workflow.** + ```bash + gh run watch + ``` + or + ```bash + gh run list --workflow=publish.yml --limit 1 + ``` + If `publish.yml` fails (typical causes: tag/package.json mismatch — caused by hand-editing `package.json` outside the script; `NPM_TOKEN` rotated; npm provenance check), report the failure verbatim and stop. Do not retry blindly. + +6. **Confirm on npm.** + ```bash + npm view genlayer-js dist-tags + ``` + The `latest` tag should show the new version. Report back to the user with the version and the GitHub Release URL. + +## Things to refuse + +- **Major bump on the current branch** without `--allow-major`. The right move for a major is a new branch + new track in the runner matrix (separate workflow). +- **Releasing from `main`** — `main` is retired. If somehow `main` exists locally, the script will refuse; explain why. +- **Hand-editing `package.json` to bump the version** instead of running the script. The script keeps `package.json`, the CHANGELOG entry, the commit message, and the tag in lockstep; doing it by hand drifts them. +- **Publishing a tag where `publish.yml` failed** — fix the underlying issue, re-cut the release (delete the bad tag both locally and on origin, re-run the script). Don't manually `npm publish`. + +## Roll-back + +If a release shipped but is broken: + +1. **Don't unpublish from npm** unless someone with elevated permissions has assessed the impact — npm unpublish has a 72-hour window and a deprecation path that consumers prefer. +2. **Deprecate the bad version**: + ```bash + npm deprecate "genlayer-js@" "broken release; install or later" + ``` +3. **Ship a follow-up patch** via the same flow (`scripts/release.sh patch`). + +## Why no auto-bump? + +The previous flow auto-bumped on every push to `main`, which: +- Twice landed accidental major bumps (`0.28.7 → 1.0.0`, `v1-prerelease → v2-yanked` in testing-suite) because conventional-commit `BREAKING CHANGE` notes are too easy to drop into a PR. +- Tied "shipping a release" to "merging a PR", which conflated two decisions. +- Left no human checkpoint between "code lands" and "users get it". + +Manual + scripted is the trade we made: small overhead per release in exchange for never shipping a surprise. diff --git a/.github/e2e-track b/.github/e2e-track index ba2906d..83b4ac5 100644 --- a/.github/e2e-track +++ b/.github/e2e-track @@ -1 +1 @@ -main +v0.5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ebefed2..340aa70 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,37 +1,28 @@ -name: Release & Publish Package to NPM +name: Publish Package to NPM -# Triggered on push to main or manual dispatch. +# 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 package.json, writes the CHANGELOG, commits, tags +# vX.Y.Z, and pushes both the branch commit and the tag. This workflow +# fires on the tag push, builds, sanity-checks the tag matches +# package.json, and publishes to npm. It never bumps or tags by itself. on: workflow_dispatch: push: - branches: - - main + tags: + - "v*" permissions: contents: write id-token: write jobs: - release: + publish: runs-on: ubuntu-latest environment: Publish steps: - - name: 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: Checkout source code + - name: Checkout tag uses: actions/checkout@v4 - with: - token: ${{ steps.ci_bot_token.outputs.token }} - - - name: Initialize Git User - run: | - git config --global user.email "github-actions[bot]@genlayer.com" - git config --global user.name "github-actions[bot]" - uses: actions/setup-node@v4 with: @@ -40,7 +31,38 @@ jobs: - run: npm ci - - name: Release - run: npm run release + - name: Verify tag matches package.json version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "Tag ($TAG_VERSION) and package.json ($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 package.json $PKG_VERSION." + + - run: npm run build + + - name: Publish to npm + run: npm publish --provenance --access public env: - GITHUB_TOKEN: ${{ steps.ci_bot_token.outputs.token }} \ No newline at end of file + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Pull the changelog block for this version out of CHANGELOG.md. + # Falls back to a generic body if the section isn't found. + 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 0f4344f..160a7e2 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`. + +- **`v1`** — current stable major. PRs for bug fixes / non-breaking features target this branch. Releases are cut from here (see `scripts/release.sh`). +- **`v-dev`** — when a major bump is in progress (e.g. `v2-dev`), this branch is open for the new major's work. PRs introducing breaking changes target this branch, not `v1`. +- **Older majors** (`v0`, etc.) 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 `v1` today. If you have a `main` branch from a previous checkout, delete it locally: + +```sh +git checkout v1 +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 npm. See `.claude/skills/release/SKILL.md` for the full flow. + +If you're using Claude Code, ask it to "release a patch" (or "release v1.2.0") and the skill walks through the pre-flight checks before invoking the script. + ### Bug fixing and Feature development #### 1. Set yourself up to start coding diff --git a/package.json b/package.json index 0447029..23c6e3a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test:watch": "vitest --watch", "lint": "eslint . --fix --ext .ts", "check:chains": "node scripts/check-chains-drift.mjs", - "release": "release-it --ci", + "release": "./scripts/release.sh", "docs": "node scripts/generate-api-docs.mjs" }, "exports": { diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..0e81522 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Cut a release on the current stable branch. +# +# Bumps package.json, prepends CHANGELOG.md, commits, tags vX.Y.Z, and +# pushes both the branch commit and the tag. publish.yml takes over from +# the tag push (build → npm publish → GitHub Release). +# +# Releases are deliberate. There is no auto-bump on push; only this +# script (or `npm version` invoked equivalently) is supposed to create +# release tags. Run from the major branch you want to ship a release on +# (e.g. v1 for v1.x.y, v0.18 for v0.18.x once that branch exists). +# +# Usage: +# scripts/release.sh # explicit semver — recommended +# scripts/release.sh patch # 1.1.8 → 1.1.9 +# scripts/release.sh minor # 1.1.8 → 1.2.0 +# scripts/release.sh major # 1.1.8 → 2.0.0 (refuses unless --allow-major) +# scripts/release.sh --allow-major +# +# 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/ (no unpushed work, no missed pulls) +# - Latest CI run on HEAD is green (so we don't ship a broken main) +# - Major bumps require --allow-major OR explicit X.0.0 with --allow-major +# since major = new branch in this repo's release model + +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="$(node -p "require('./package.json').version")" + +# Resolve to a concrete X.Y.Z so the major-bump guard can compare. +case "$VERSION_ARG" in + major|minor|patch) + next_version="$(node -e " + const semver = require('semver'); + const cur = require('./package.json').version; + const inc = '$VERSION_ARG'; + const out = semver.inc(cur, inc); + if (!out) { console.error('semver.inc failed for', cur, inc); process.exit(1); } + console.log(out); + ")" + ;; + *) + next_version="$VERSION_ARG" + ;; +esac + +# Validate semver shape early so we don't half-bump. +if ! node -e "if (!require('semver').valid('$next_version')) process.exit(1)"; then + echo "Not a valid semver: $next_version" >&2 + exit 2 +fi + +cur_major="${current_version%%.*}" +next_major="${next_version%%.*}" +if [ "$next_major" != "$cur_major" ] && [ "$ALLOW_MAJOR" -ne 1 ]; then + cat >&2 <