diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index fad105d6..9326fd5b 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -8,6 +8,7 @@ For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This Use these scripts instead of older one-off publish commands: +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release - [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes - [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push diff --git a/doc/RELEASING.md b/doc/RELEASING.md index cab82cbe..e18a3e6e 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -26,24 +26,20 @@ Treat those as related but separate. npm can succeed while the GitHub Release is Use this when you want an installable prerelease without changing `latest`. ```bash -# 0. Start clean -git status --short +# 0. Preflight the canary candidate +./scripts/release-preflight.sh canary patch -# 1. Verify the candidate SHA -pnpm -r typecheck -pnpm test:run -pnpm build +# 1. Draft or update the stable changelog for the intended stable version +VERSION=0.2.8 +claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." -# 2. Draft or update the stable changelog -# releases/vX.Y.Z.md - -# 3. Preview the canary release +# 2. Preview the canary release ./scripts/release.sh patch --canary --dry-run -# 4. Publish the canary +# 3. Publish the canary ./scripts/release.sh patch --canary -# 5. Smoke test what users will actually install +# 4. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh # Users install with: @@ -57,6 +53,7 @@ Result: - no git tag is created - no GitHub Release is created - the working tree returns to clean after the script finishes +- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N` ### Stable release @@ -66,15 +63,13 @@ Use this only after the canary SHA is good enough to become the public default. # 0. Start from the vetted commit git checkout master git pull -git status --short -# 1. Verify again on the exact release SHA -pnpm -r typecheck -pnpm test:run -pnpm build +# 1. Preflight the stable candidate +./scripts/release-preflight.sh stable patch # 2. Confirm the stable changelog exists -ls releases/v*.md +VERSION=0.2.8 +ls "releases/v${VERSION}.md" # 3. Preview the stable publish ./scripts/release.sh patch --dry-run @@ -174,6 +169,15 @@ pnpm build This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. +For release work, prefer: + +```bash +./scripts/release-preflight.sh canary +./scripts/release-preflight.sh stable +``` + +That script runs the verification gate and prints the computed target versions before you publish anything. + ## Versioning Policy ### Stable versions @@ -200,6 +204,11 @@ That gives you three useful properties: We do **not** create separate changelog files for canary versions. +Concrete example: + +- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0` +- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version + ## Changelog Policy The maintainer changelog source of truth is: @@ -222,7 +231,23 @@ Package-level `CHANGELOG.md` files are generated as part of the release mechanic ### 1. Decide the bump -Review the range since the last stable tag: +Run preflight first: + +```bash +./scripts/release-preflight.sh canary +# or +./scripts/release-preflight.sh stable +``` + +That command: + +- verifies the worktree is clean, including untracked files +- shows the last stable tag and computed next versions +- shows the commit range since the last stable tag +- highlights migration and breaking-change signals +- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` + +If you want the raw inputs separately, review the range since the last stable tag: ```bash LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) @@ -239,7 +264,8 @@ Use the higher bump if there is any doubt. Create or update: ```bash -releases/vX.Y.Z.md +VERSION=X.Y.Z +claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." ``` This is deliberate. The release notes should describe the stable story, not the canary mechanics. @@ -270,6 +296,12 @@ This means the script is safe to repeat as many times as needed while iterating: The target stable release can still remain `1.2.3`. +Guardrail: + +- the canary is always derived from the **next stable version** +- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0` +- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release + ### 4. Smoke test the canary Run the actual install path in Docker: @@ -426,6 +458,7 @@ Rollback procedure: ## Scripts Reference - [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push - [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release - [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI diff --git a/package.json b/package.json index 737438ec..68098ad8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", "release": "./scripts/release.sh", + "release:preflight": "./scripts/release-preflight.sh", "release:github": "./scripts/create-github-release.sh", "release:rollback": "./scripts/rollback-latest.sh", "changeset": "changeset", diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh new file mode 100755 index 00000000..575fbcc1 --- /dev/null +++ b/scripts/release-preflight.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +channel="" +bump_type="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/release-preflight.sh + +Examples: + ./scripts/release-preflight.sh canary patch + ./scripts/release-preflight.sh stable minor + +What it does: + - verifies the git worktree is clean, including untracked files + - shows the last stable tag and the target version(s) + - shows commits since the last stable tag + - highlights migration/schema/breaking-change signals + - runs the verification gate: + pnpm -r typecheck + pnpm test:run + pnpm build +EOF +} + +if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then + usage + exit 0 +fi + +if [ $# -ne 2 ]; then + usage + exit 1 +fi + +channel="$1" +bump_type="$2" + +if [[ ! "$channel" =~ ^(canary|stable)$ ]]; then + usage + exit 1 +fi + +if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then + usage + exit 1 +fi + +compute_bumped_version() { + node - "$1" "$2" <<'NODE' +const current = process.argv[2]; +const bump = process.argv[3]; +const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/); + +if (!match) { + throw new Error(`invalid semver version: ${current}`); +} + +let [major, minor, patch] = match.slice(1).map(Number); + +if (bump === 'patch') { + patch += 1; +} else if (bump === 'minor') { + minor += 1; + patch = 0; +} else if (bump === 'major') { + major += 1; + minor = 0; + patch = 0; +} else { + throw new Error(`unsupported bump type: ${bump}`); +} + +process.stdout.write(`${major}.${minor}.${patch}`); +NODE +} + +next_canary_version() { + local stable_version="$1" + local versions_json + + versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')" + + node - "$stable_version" "$versions_json" <<'NODE' +const stable = process.argv[2]; +const versionsArg = process.argv[3]; + +let versions = []; +try { + const parsed = JSON.parse(versionsArg); + versions = Array.isArray(parsed) ? parsed : [parsed]; +} catch { + versions = []; +} + +const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); +let max = -1; + +for (const version of versions) { + const match = version.match(pattern); + if (!match) continue; + max = Math.max(max, Number(match[1])); +} + +process.stdout.write(`${stable}-canary.${max + 1}`); +NODE +} + +LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)" +CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}" +if [ -z "$CURRENT_STABLE_VERSION" ]; then + CURRENT_STABLE_VERSION="0.0.0" +fi + +TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" +TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" + +if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2 + exit 1 +fi + +if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then + echo "Error: next stable version matches the current stable version." >&2 + exit 1 +fi + +if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then + echo "Error: canary target was derived from the current stable version, which is not allowed." >&2 + exit 1 +fi + +echo "" +echo "==> Release preflight" +echo " Channel: $channel" +echo " Bump: $bump_type" +echo " Last stable tag: ${LAST_STABLE_TAG:-}" +echo " Current stable version: $CURRENT_STABLE_VERSION" +echo " Next stable version: $TARGET_STABLE_VERSION" +if [ "$channel" = "canary" ]; then + echo " Next canary version: $TARGET_CANARY_VERSION" + echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" +fi + +echo "" +echo "==> Working tree" +echo " ✓ Clean" + +echo "" +echo "==> Commits since last stable tag" +if [ -n "$LAST_STABLE_TAG" ]; then + git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true +else + git -C "$REPO_ROOT" log --oneline --no-merges || true +fi + +echo "" +echo "==> Migration / breaking change signals" +if [ -n "$LAST_STABLE_TAG" ]; then + echo "-- migrations --" + git -C "$REPO_ROOT" diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true + echo "-- schema --" + git -C "$REPO_ROOT" diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true + echo "-- breaking commit messages --" + git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +else + echo "No stable tag exists yet. Review the full current tree manually." +fi + +echo "" +echo "==> Verification gate" +cd "$REPO_ROOT" +pnpm -r typecheck +pnpm test:run +pnpm build + +echo "" +echo "Preflight passed for $channel release." diff --git a/scripts/release.sh b/scripts/release.sh index 4908912c..1c05e19c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -309,6 +309,14 @@ if [ "$canary" = true ]; then TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" fi +if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then + fail "next stable version matches the current stable version. Refusing to publish." +fi + +if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then + fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N." +fi + PUBLIC_PACKAGE_INFO="$(list_public_package_info)" PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)" PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)" @@ -324,6 +332,7 @@ info " Current stable version: $CURRENT_STABLE_VERSION" if [ "$canary" = true ]; then info " Target stable version: $TARGET_STABLE_VERSION" info " Canary version: $TARGET_PUBLISH_VERSION" + info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N" else info " Stable version: $TARGET_STABLE_VERSION" fi diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 65468704..088ed7ba 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -69,7 +69,15 @@ Critical consequence: ## Step 1 — Decide the Stable Version -Use the last stable tag as the base: +Run release preflight first: + +```bash +./scripts/release-preflight.sh canary {patch|minor|major} +# or +./scripts/release-preflight.sh stable {patch|minor|major} +``` + +Then use the last stable tag as the base: ```bash LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) @@ -128,6 +136,11 @@ What this means: - no git tag is created - the script cleans the working tree afterward +Guard: + +- if the current stable is `0.2.7`, the next patch canary is `0.2.8-canary.0` +- the tooling must never publish `0.2.7-canary.N` after `0.2.7` is already stable + After publish, verify: ```bash