Skip to content
Merged
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
99 changes: 99 additions & 0 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -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<old>`.

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 <X.Y.Z> # or patch / minor
```
It will: bump `package.json`, prepend `CHANGELOG.md`, commit `Release v<X.Y.Z> [skip ci]`, tag `v<X.Y.Z>`, 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@<X.Y.Z>" "broken release; install <X.Y.Z+1> 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.
2 changes: 1 addition & 1 deletion .github/e2e-track
Original file line number Diff line number Diff line change
@@ -1 +1 @@
main
v0.5
68 changes: 45 additions & 23 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 }}
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"
22 changes: 22 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<next>-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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
148 changes: 148 additions & 0 deletions scripts/release.sh
Original file line number Diff line number Diff line change
@@ -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 <X.Y.Z> # 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 <X.Y.Z>
#
# Pre-flight (each check refuses to proceed on failure):
# - On a v<MAJOR> branch (refuses on main / feature branches)
# - Working tree clean
# - Local HEAD matches origin/<branch> (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] <X.Y.Z>|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 <<EOF
Refusing to release from '$branch'.

Release branches in this repo are named after the major they ship
(v1, v2, ...) or the next-major dev line (v2-dev, v3-dev). main has
been retired — see CONTRIBUTING.md for the branch model.

If you intended to ship a v1.x release, run:
git checkout v1 && git pull --ff-only && scripts/release.sh ...
EOF
exit 1
fi

if [ -n "$(git status --porcelain)" ]; then
echo "Working tree not clean. Stash or commit first." >&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 <<EOF
Local $branch ($local_sha) does not match origin/$branch ($remote_sha).
Pull (or push) before releasing so the published tag is reachable from
the branch's public history.
EOF
exit 1
fi

# Latest CI conclusion on HEAD must be success. Skips this check when gh
# isn't installed (operator can override on a machine without gh, but
# CI/Claude flows always have gh).
if command -v gh >/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 <<EOF
Refusing major bump $current_version → $next_version without --allow-major.

In this repo's release model, a major bump means cutting a new branch
(v$next_major) and switching the default. Don't tag a major on top of
the v$cur_major branch — see CONTRIBUTING.md.

If you actually want this (rare, e.g. retroactively shipping a major
that was developed on v$next_major-dev), pass --allow-major.
EOF
exit 1
fi

echo "Releasing v$next_version on $branch (was v$current_version)."

# release-it does the bump + CHANGELOG generation + commit + tag + push
# in one shot. --no-npm.publish keeps the npm push out of the dev
# machine (publish.yml does that on the tag arrival). --no-github.release
# similarly defers the GH release to publish.yml. release-it's whatBump
# logic isn't exercised here because we're passing an explicit version.
npx release-it "$next_version" --ci \
--no-npm.publish \
--no-github.release

echo
echo "Pushed v$next_version. publish.yml will fire on the tag and ship to npm."
echo "Track it at: https://github.com/genlayerlabs/genlayer-js/actions"
Loading