diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..492b02b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release + +on: + workflow_dispatch: + inputs: + channel: + description: Release channel + required: true + type: choice + default: canary + options: + - canary + - stable + bump: + description: Semantic version bump + required: true + type: choice + default: patch + options: + - patch + - minor + - major + dry_run: + description: Preview the release without publishing + required: true + type: boolean + default: true + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Run tests + run: pnpm test:run + + - name: Build + run: pnpm build + + publish: + if: github.ref == 'refs/heads/master' + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: npm-release + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run release script + env: + GITHUB_ACTIONS: "true" + run: | + args=("${{ inputs.bump }}") + if [ "${{ inputs.channel }}" = "canary" ]; then + args+=("--canary") + fi + if [ "${{ inputs.dry_run }}" = "true" ]; then + args+=("--dry-run") + fi + ./scripts/release.sh "${args[@]}" + + - name: Push stable release commit and tag + if: inputs.channel == 'stable' && !inputs.dry_run + run: git push origin HEAD:master --follow-tags + + - name: Create GitHub Release + if: inputs.channel == 'stable' && !inputs.dry_run + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')" + if [ -z "$version" ]; then + echo "Error: no v* tag points at HEAD after stable release." >&2 + exit 1 + fi + ./scripts/create-github-release.sh "$version" diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 29ac7291..fad105d6 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -1,196 +1,119 @@ # Publishing to npm -This document covers how to build and publish the `paperclipai` CLI package to npm. +Low-level reference for how Paperclip packages are built for npm. -## Prerequisites +For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This document is only about packaging internals and the scripts that produce publishable artifacts. -- Node.js 20+ -- pnpm 9.15+ -- An npm account with publish access to the `paperclipai` package -- Logged in to npm: `npm login` +## Current Release Entry Points -## One-Command Publish +Use these scripts instead of older one-off publish commands: -The fastest way to publish — bumps version, builds, publishes, restores, commits, and tags in one shot: +- [`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 -```bash -./scripts/bump-and-publish.sh patch # 0.1.1 → 0.1.2 -./scripts/bump-and-publish.sh minor # 0.1.1 → 0.2.0 -./scripts/bump-and-publish.sh major # 0.1.1 → 1.0.0 -./scripts/bump-and-publish.sh 2.0.0 # set explicit version -./scripts/bump-and-publish.sh patch --dry-run # everything except npm publish -``` +## Why the CLI needs special packaging -The script runs all 6 steps below in order. It requires a clean working tree and an active `npm login` session (unless `--dry-run`). After it finishes, push: +The CLI package, `paperclipai`, imports code from workspace packages such as: -```bash -git push && git push origin v -``` +- `@paperclipai/server` +- `@paperclipai/db` +- `@paperclipai/shared` +- adapter packages under `packages/adapters/` -## Manual Step-by-Step +Those workspace references use `workspace:*` during development. npm cannot install those references directly for end users, so the release build has to transform the CLI into a publishable standalone package. -If you prefer to run each step individually: +## `build-npm.sh` -### Quick Reference - -```bash -# Bump version -./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 - -# Build -./scripts/build-npm.sh - -# Preview what will be published -cd cli && npm pack --dry-run - -# Publish -cd cli && npm publish --access public - -# Restore dev package.json -mv cli/package.dev.json cli/package.json -``` - -## Step-by-Step - -### 1. Bump the version - -```bash -./scripts/version-bump.sh -``` - -This updates the version in two places: - -- `cli/package.json` — the source of truth -- `cli/src/index.ts` — the Commander `.version()` call - -Examples: - -```bash -./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 -./scripts/version-bump.sh minor # 0.1.0 → 0.2.0 -./scripts/version-bump.sh major # 0.1.0 → 1.0.0 -./scripts/version-bump.sh 1.2.3 # set explicit version -``` - -### 2. Build +Run: ```bash ./scripts/build-npm.sh ``` -The build script runs five steps: +This script does six things: -1. **Forbidden token check** — scans tracked files for tokens listed in `.git/hooks/forbidden-tokens.txt`. If the file is missing (e.g. on a contributor's machine), the check passes silently. The script never prints which tokens it's searching for. -2. **TypeScript type-check** — runs `pnpm -r typecheck` across all workspace packages. -3. **esbuild bundle** — bundles the CLI entry point (`cli/src/index.ts`) and all workspace package code (`@paperclipai/*`) into a single file at `cli/dist/index.js`. External npm dependencies (express, postgres, etc.) are kept as regular imports. -4. **Generate publishable package.json** — replaces `cli/package.json` with a version that has real npm dependency ranges instead of `workspace:*` references (see [package.dev.json](#packagedevjson) below). -5. **Summary** — prints the bundle size and next steps. +1. Runs the forbidden token check unless `--skip-checks` is supplied +2. Runs `pnpm -r typecheck` +3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js` +4. Verifies the bundled entrypoint with `node --check` +5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json` +6. Copies the repo `README.md` into `cli/README.md` for npm package metadata -To skip the forbidden token check (e.g. in CI without the token list): +`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies. + +## Publishable CLI layout + +During development, [`cli/package.json`](../cli/package.json) contains workspace references. + +During release preparation: + +- `cli/package.json` becomes a publishable manifest with external npm dependency ranges +- `cli/package.dev.json` stores the development manifest temporarily +- `cli/dist/index.js` contains the bundled CLI entrypoint +- `cli/README.md` is copied in for npm metadata + +After release finalization, the release script restores the development manifest and removes the temporary README copy. + +## Package discovery + +The release tooling scans the workspace for public packages under: + +- `packages/` +- `server/` +- `cli/` + +`ui/` remains ignored for npm publishing because it is private. + +This matters because all public packages are versioned and published together as one release unit. + +## Canary packaging model + +Canaries are published as semver prereleases such as: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` + +They are published under the npm dist-tag `canary`. + +This means: + +- `npx paperclipai@canary onboard` can install them explicitly +- `npx paperclipai onboard` continues to resolve `latest` +- the stable changelog can stay at `releases/v1.2.3.md` + +## Stable packaging model + +Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`. + +The stable publish flow also creates the local release commit and git tag. Pushing the commit/tag and creating the GitHub Release happen afterward as separate maintainer steps. + +## Rollback model + +Rollback does not unpublish packages. + +Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with: ```bash -./scripts/build-npm.sh --skip-checks +./scripts/rollback-latest.sh ``` -### 3. Preview (optional) +That keeps history intact while restoring the default install path quickly. -See what npm will publish: +## Notes for CI -```bash -cd cli && npm pack --dry-run -``` +The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). -### 4. Publish +Recommended CI release setup: -```bash -cd cli && npm publish --access public -``` +- use npm trusted publishing via GitHub OIDC +- require approval through the `npm-release` environment +- run releases from `master` +- use canary first, then stable -### 5. Restore dev package.json +## Related Files -After publishing, restore the workspace-aware `package.json`: - -```bash -mv cli/package.dev.json cli/package.json -``` - -### 6. Commit and tag - -```bash -git add cli/package.json cli/src/index.ts -git commit -m "chore: bump version to X.Y.Z" -git tag vX.Y.Z -``` - -## package.dev.json - -During development, `cli/package.json` contains `workspace:*` references like: - -```json -{ - "dependencies": { - "@paperclipai/server": "workspace:*", - "@paperclipai/db": "workspace:*" - } -} -``` - -These tell pnpm to resolve those packages from the local monorepo. This is great for development but **npm doesn't understand `workspace:*`** — publishing with these references would cause install failures for users. - -The build script solves this with a two-file swap: - -1. **Before building:** `cli/package.json` has `workspace:*` refs (the dev version). -2. **During build (`build-npm.sh` step 4):** - - The dev `package.json` is copied to `package.dev.json` as a backup. - - `generate-npm-package-json.mjs` reads every workspace package's `package.json`, collects all their external npm dependencies, and writes a new `cli/package.json` with those real dependency ranges — no `workspace:*` refs. -3. **After publishing:** you restore the dev version with `mv package.dev.json package.json`. - -The generated publishable `package.json` looks like: - -```json -{ - "name": "paperclipai", - "version": "0.1.0", - "bin": { "paperclipai": "./dist/index.js" }, - "dependencies": { - "express": "^5.1.0", - "postgres": "^3.4.5", - "commander": "^13.1.0" - } -} -``` - -`package.dev.json` is listed in `.gitignore` — it only exists temporarily on disk during the build/publish cycle. - -## How the bundle works - -The CLI is a monorepo package that imports code from `@paperclipai/server`, `@paperclipai/db`, `@paperclipai/shared`, and several adapter packages. These workspace packages don't exist on npm. - -**esbuild** bundles all workspace TypeScript code into a single `dist/index.js` file (~250kb). External npm packages (express, postgres, zod, etc.) are left as normal `import` statements — they get installed by npm when a user runs `npx paperclipai onboard`. - -The esbuild configuration lives at `cli/esbuild.config.mjs`. It automatically reads every workspace package's `package.json` to determine which dependencies are external (real npm packages) vs. internal (workspace code to bundle). - -## Forbidden token enforcement - -The build process includes the same forbidden-token check used by the git pre-commit hook. This catches any accidentally committed tokens before they reach npm. - -- Token list: `.git/hooks/forbidden-tokens.txt` (one token per line, `#` comments supported) -- The file lives inside `.git/` and is never committed -- If the file is missing, the check passes — contributors without the list can still build -- The script never prints which tokens are being searched for -- Matches are printed so you know which files to fix, but not which token triggered it - -Run the check standalone: - -```bash -pnpm check:tokens -``` - -## npm scripts reference - -| Script | Command | Description | -|---|---|---| -| `bump-and-publish` | `pnpm bump-and-publish ` | One-command bump + build + publish + commit + tag | -| `build:npm` | `pnpm build:npm` | Full build (check + typecheck + bundle + package.json) | -| `version:bump` | `pnpm version:bump ` | Bump CLI version | -| `check:tokens` | `pnpm check:tokens` | Run forbidden token check only | +- [`scripts/build-npm.sh`](../scripts/build-npm.sh) +- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs) +- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs) +- [`doc/RELEASING.md`](RELEASING.md) diff --git a/doc/RELEASING.md b/doc/RELEASING.md new file mode 100644 index 00000000..cab82cbe --- /dev/null +++ b/doc/RELEASING.md @@ -0,0 +1,437 @@ +# Releasing Paperclip + +Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface. + +This document is intentionally practical: + +- TL;DR command sequences are at the top. +- Detailed checklists come next. +- Motivation, failure handling, and rollback playbooks follow after that. + +## Release Surfaces + +Every Paperclip release has four separate surfaces: + +1. **Verification** — the exact git SHA must pass typecheck, tests, and build. +2. **npm** — `paperclipai` and the public workspace packages are published. +3. **GitHub** — the stable release gets a git tag and a GitHub Release. +4. **Website / announcements** — the stable changelog is published externally and announced. + +Treat those as related but separate. npm can succeed while the GitHub Release is still pending. GitHub can be correct while the website changelog is stale. A maintainer release is done only when all four surfaces are handled. + +## TL;DR + +### Canary release + +Use this when you want an installable prerelease without changing `latest`. + +```bash +# 0. Start clean +git status --short + +# 1. Verify the candidate SHA +pnpm -r typecheck +pnpm test:run +pnpm build + +# 2. Draft or update the stable changelog +# releases/vX.Y.Z.md + +# 3. Preview the canary release +./scripts/release.sh patch --canary --dry-run + +# 4. Publish the canary +./scripts/release.sh patch --canary + +# 5. Smoke test what users will actually install +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh + +# Users install with: +npx paperclipai@canary onboard +``` + +Result: + +- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary` +- `latest` is unchanged +- no git tag is created +- no GitHub Release is created +- the working tree returns to clean after the script finishes + +### Stable release + +Use this only after the canary SHA is good enough to become the public default. + +```bash +# 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 + +# 2. Confirm the stable changelog exists +ls releases/v*.md + +# 3. Preview the stable publish +./scripts/release.sh patch --dry-run + +# 4. Publish the stable release to npm and create the local release commit + tag +./scripts/release.sh patch + +# 5. Push the release commit and tag +git push origin HEAD:master --follow-tags + +# 6. Create or update the GitHub Release from the pushed tag +./scripts/create-github-release.sh X.Y.Z +``` + +Result: + +- npm gets stable `X.Y.Z` under dist-tag `latest` +- a local git commit and tag `vX.Y.Z` are created +- after push, GitHub gets the matching Release +- the website and announcement steps still need to be handled manually + +### Emergency rollback + +If `latest` is broken after publish, repoint it to the last known good stable version first, then work on the fix. + +```bash +# Preview +./scripts/rollback-latest.sh X.Y.Z --dry-run + +# Roll back latest for every public package +./scripts/rollback-latest.sh X.Y.Z +``` + +This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. + +### GitHub Actions release + +There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. + +Use it from the Actions tab: + +1. Choose `Release` +2. Choose `channel`: `canary` or `stable` +3. Choose `bump`: `patch`, `minor`, or `major` +4. Choose whether this is a `dry_run` +5. Run it from `master` + +The workflow: + +- reruns `typecheck`, `test:run`, and `build` +- gates publish behind the `npm-release` environment +- can publish canaries without touching `latest` +- can publish stable, push the release commit and tag, and create the GitHub Release + +## Release Checklist + +### Before any publish + +- [ ] The working tree is clean, including untracked files +- [ ] The target branch and SHA are the ones you actually want to release +- [ ] The required verification gate passed on that exact SHA +- [ ] The bump type is correct for the user-visible impact +- [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md` +- [ ] You know which previous stable version you would roll back to if needed + +### Before a canary + +- [ ] You are intentionally testing something that should be installable before it becomes default +- [ ] You are comfortable with users installing it via `npx paperclipai@canary onboard` +- [ ] You understand that each canary is a new immutable npm version such as `1.2.3-canary.1` + +### Before a stable + +- [ ] The candidate has already passed smoke testing +- [ ] The changelog should be the stable version only, for example `v1.2.3` +- [ ] You are ready to push the release commit and tag immediately after npm publish +- [ ] You are ready to create the GitHub Release immediately after the push +- [ ] You have a post-release website / announcement plan + +### After a stable + +- [ ] `npm view paperclipai@latest version` matches the new stable version +- [ ] The git tag exists on GitHub +- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md` +- [ ] The website changelog is updated +- [ ] Any announcement copy matches the shipped release, not the canary + +## Verification Gate + +The repository standard is: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. + +## Versioning Policy + +### Stable versions + +Stable releases use normal semver: + +- `patch` for bug fixes +- `minor` for additive features, endpoints, and additive migrations +- `major` for destructive migrations, removed APIs, or other breaking behavior + +### Canary versions + +Canaries are semver prereleases of the **intended stable version**: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` +- `1.2.3-canary.2` + +That gives you three useful properties: + +1. Users can install the prerelease explicitly with `@canary` +2. `latest` stays safe +3. The stable changelog can remain just `v1.2.3` + +We do **not** create separate changelog files for canary versions. + +## Changelog Policy + +The maintainer changelog source of truth is: + +- `releases/vX.Y.Z.md` + +That file is for the eventual stable release. It should not include `-canary` in the filename or heading. + +Recommended structure: + +- `Breaking Changes` when needed +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed + +Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative. + +## Detailed Workflow + +### 1. Decide the bump + +Review the range since the last stable tag: + +```bash +LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) +git log "${LAST_TAG}..HEAD" --oneline --no-merges +git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ +git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ +git log "${LAST_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +``` + +Use the higher bump if there is any doubt. + +### 2. Write the stable changelog first + +Create or update: + +```bash +releases/vX.Y.Z.md +``` + +This is deliberate. The release notes should describe the stable story, not the canary mechanics. + +### 3. Publish one or more canaries + +Run: + +```bash +./scripts/release.sh --canary +``` + +What the script does: + +1. Verifies the working tree is clean +2. Computes the intended stable version from the last stable tag +3. Computes the next canary ordinal from npm +4. Versions the public packages to `X.Y.Z-canary.N` +5. Builds the workspace and publishable CLI +6. Publishes to npm under dist-tag `canary` +7. Cleans up the temporary versioning state so your branch returns to clean + +This means the script is safe to repeat as many times as needed while iterating: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` +- `1.2.3-canary.2` + +The target stable release can still remain `1.2.3`. + +### 4. Smoke test the canary + +Run the actual install path in Docker: + +```bash +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +Minimum checks: + +- [ ] `npx paperclipai@canary onboard` installs +- [ ] onboarding completes without crashes +- [ ] the server boots +- [ ] the UI loads +- [ ] basic company creation and dashboard load work + +### 5. Publish stable from the vetted commit + +Once the candidate SHA is good, run the stable flow on that exact commit: + +```bash +./scripts/release.sh +``` + +What the script does: + +1. Verifies the working tree is clean +2. Versions the public packages to the stable semver +3. Builds the workspace and CLI publish bundle +4. Publishes to npm under `latest` +5. Restores temporary publish artifacts +6. Creates the local release commit and git tag + +What it does **not** do: + +- it does not push for you +- it does not update the website +- it does not announce the release for you + +### 6. Push the release and create the GitHub Release + +After a stable publish succeeds: + +```bash +git push origin HEAD:master --follow-tags +./scripts/create-github-release.sh X.Y.Z +``` + +The GitHub release notes come from: + +- `releases/vX.Y.Z.md` + +### 7. Complete the external surfaces + +After GitHub is correct: + +- publish the changelog on the website +- write the announcement copy +- ensure public docs and install guidance point to the stable version + +## GitHub Actions and npm Trusted Publishing + +If you want GitHub to own the actual npm publish, use [`.github/workflows/release.yml`](../.github/workflows/release.yml) together with npm trusted publishing. + +Recommended setup: + +1. Configure the GitHub Actions workflow as a trusted publisher for **every public package** on npm +2. Use the `npm-release` GitHub environment with required reviewers +3. Run stable publishes from `master` only +4. Keep the workflow manual via `workflow_dispatch` + +Why this is the right shape: + +- no long-lived npm token needs to live in GitHub secrets +- reviewers can approve the publish step at the environment gate +- the workflow reruns verification on the release SHA before publish +- stable and canary use the same mechanics + +## Failure Playbooks + +### If the canary fails before publish + +Nothing shipped. Fix the code and rerun the canary workflow. + +### If the canary publishes but the smoke test fails + +Do **not** publish stable. + +Instead: + +1. Fix the issue +2. Publish another canary +3. Re-run smoke testing + +The canary version number will increase, but the stable target version can remain the same. + +### If the stable npm publish succeeds but push fails + +This is a partial release. npm is already live. + +Do this immediately: + +1. Fix the git issue +2. Push the release commit and tag from the same checkout +3. Create the GitHub Release + +Do **not** publish the same version again. + +### If the stable release is bad after `latest` moves + +Use the rollback script first: + +```bash +./scripts/rollback-latest.sh +``` + +Then: + +1. open an incident note or maintainer comment +2. fix forward on a new patch release +3. update the changelog / release notes if the user-facing guidance changed + +### If the GitHub Release is wrong + +Edit it by re-running: + +```bash +./scripts/create-github-release.sh X.Y.Z +``` + +This updates the release notes if the GitHub Release already exists. + +### If the website changelog is wrong + +Fix the website independently. Do not republish npm just to repair the website surface. + +## Rollback Strategy + +The default rollback strategy is **dist-tag rollback, then fix forward**. + +Why: + +- npm versions are immutable +- users need `npx paperclipai onboard` to recover quickly +- moving `latest` back is faster and safer than trying to delete history + +Rollback procedure: + +1. identify the last known good stable version +2. run `./scripts/rollback-latest.sh ` +3. verify `npm view paperclipai@latest version` +4. fix forward with a new stable release + +## Scripts Reference + +- [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow +- [`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 + +## Related Docs + +- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals +- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow +- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow diff --git a/package.json b/package.json index e19fb785..737438ec 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", "release": "./scripts/release.sh", + "release:github": "./scripts/create-github-release.sh", + "release:rollback": "./scripts/rollback-latest.sh", "changeset": "changeset", "version-packages": "changeset version", "check:tokens": "node scripts/check-forbidden-tokens.mjs", diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh new file mode 100755 index 00000000..4d1d0789 --- /dev/null +++ b/scripts/create-github-release.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +dry_run=false +version="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/create-github-release.sh [--dry-run] + +Examples: + ./scripts/create-github-release.sh 1.2.3 + ./scripts/create-github-release.sh 1.2.3 --dry-run + +Notes: + - Run this after pushing the release commit and tag. + - If the release already exists, this script updates its title and notes. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=true ;; + -h|--help) + usage + exit 0 + ;; + *) + if [ -n "$version" ]; then + echo "Error: only one version may be provided." >&2 + exit 1 + fi + version="$1" + ;; + esac + shift +done + +if [ -z "$version" ]; then + usage + exit 1 +fi + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be a stable semver like 1.2.3." >&2 + exit 1 +fi + +tag="v$version" +notes_file="$REPO_ROOT/releases/${tag}.md" + +if ! command -v gh >/dev/null 2>&1; then + echo "Error: gh CLI is required to create GitHub releases." >&2 + exit 1 +fi + +if [ ! -f "$notes_file" ]; then + echo "Error: release notes file not found at $notes_file." >&2 + exit 1 +fi + +if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then + echo "Error: local git tag $tag does not exist." >&2 + exit 1 +fi + +if [ "$dry_run" = true ]; then + echo "[dry-run] gh release create $tag --title $tag --notes-file $notes_file" + exit 0 +fi + +if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "Error: remote tag $tag was not found on origin. Push the release commit and tag first." >&2 + exit 1 +fi + +if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --title "$tag" --notes-file "$notes_file" + echo "Updated GitHub Release $tag" +else + gh release create "$tag" --title "$tag" --notes-file "$notes_file" + echo "Created GitHub Release $tag" +fi diff --git a/scripts/release.sh b/scripts/release.sh index 3668d87c..4908912c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,420 +1,460 @@ #!/usr/bin/env bash set -euo pipefail -# release.sh — One-command version bump, build, and publish via Changesets. +# release.sh — Prepare and publish a Paperclip release. # -# Usage: -# ./scripts/release.sh patch # 0.2.0 → 0.2.1 -# ./scripts/release.sh minor # 0.2.0 → 0.3.0 -# ./scripts/release.sh major # 0.2.0 → 1.0.0 -# ./scripts/release.sh patch --dry-run # everything except npm publish -# ./scripts/release.sh patch --canary # publish under @canary tag, no commit/tag -# ./scripts/release.sh patch --canary --dry-run -# ./scripts/release.sh --promote 0.2.8 # promote canary to @latest + commit/tag -# ./scripts/release.sh --promote 0.2.8 --dry-run +# Stable release: +# ./scripts/release.sh patch +# ./scripts/release.sh minor --dry-run # -# Steps (normal): -# 1. Preflight checks (clean tree, npm login) -# 2. Auto-create a changeset for all public packages -# 3. Run changeset version (bumps versions, generates CHANGELOGs) -# 4. Build all packages -# 5. Build CLI bundle (esbuild) -# 6. Publish to npm via changeset publish (unless --dry-run) -# 7. Commit and tag +# Canary release: +# ./scripts/release.sh patch --canary +# ./scripts/release.sh minor --canary --dry-run # -# --canary: Steps 1-5 unchanged, Step 6 publishes with --tag canary, Step 7 skipped. -# --promote: Skips Steps 1-6, promotes canary to latest, then commits and tags. +# Canary releases publish prerelease versions such as 1.2.3-canary.0 under the +# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest". REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI_DIR="$REPO_ROOT/cli" - -# ── Helper: create GitHub Release ──────────────────────────────────────────── -create_github_release() { - local version="$1" - local is_dry_run="$2" - local release_notes="$REPO_ROOT/releases/v${version}.md" - - if [ "$is_dry_run" = true ]; then - echo " [dry-run] gh release create v$version" - return - fi - - if ! command -v gh &>/dev/null; then - echo " ⚠ gh CLI not found — skipping GitHub Release" - return - fi - - local gh_args=(gh release create "v$version" --title "v$version") - if [ -f "$release_notes" ]; then - gh_args+=(--notes-file "$release_notes") - else - gh_args+=(--generate-notes) - fi - - if "${gh_args[@]}"; then - echo " ✓ Created GitHub Release v$version" - else - echo " ⚠ GitHub Release creation failed (non-fatal)" - fi -} - -# ── Parse args ──────────────────────────────────────────────────────────────── +TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" +TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json" dry_run=false canary=false -promote=false -promote_version="" bump_type="" +cleanup_on_exit=false + +usage() { + cat <<'EOF' +Usage: + ./scripts/release.sh [--canary] [--dry-run] + +Examples: + ./scripts/release.sh patch + ./scripts/release.sh minor --dry-run + ./scripts/release.sh patch --canary + ./scripts/release.sh minor --canary --dry-run + +Notes: + - Canary publishes prerelease versions like 1.2.3-canary.0 under the npm + dist-tag "canary". + - Stable publishes 1.2.3 under the npm dist-tag "latest". + - Dry runs leave the working tree clean. +EOF +} + while [ $# -gt 0 ]; do case "$1" in --dry-run) dry_run=true ;; --canary) canary=true ;; + -h|--help) + usage + exit 0 + ;; --promote) - promote=true - shift - if [ $# -eq 0 ] || [[ "$1" == --* ]]; then - echo "Error: --promote requires a version argument (e.g. --promote 0.2.8)" + echo "Error: --promote was removed. Re-run a stable release from the vetted commit instead." + exit 1 + ;; + *) + if [ -n "$bump_type" ]; then + echo "Error: only one bump type may be provided." exit 1 fi - promote_version="$1" + bump_type="$1" ;; - *) bump_type="$1" ;; esac shift done -if [ "$promote" = true ] && [ "$canary" = true ]; then - echo "Error: --canary and --promote cannot be used together" +if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then + usage exit 1 fi -if [ "$promote" = false ]; then - if [ -z "$bump_type" ]; then - echo "Usage: $0 [--dry-run] [--canary]" - echo " $0 --promote [--dry-run]" - exit 1 - fi - - if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then - echo "Error: bump type must be patch, minor, or major (got '$bump_type')" - exit 1 - fi -fi - -# ── Promote mode (skips Steps 1-6) ─────────────────────────────────────────── - -if [ "$promote" = true ]; then - NEW_VERSION="$promote_version" - echo "" - echo "==> Promote mode: promoting v$NEW_VERSION from canary to latest..." - - # Get all publishable package names - PACKAGES=$(node -e " -const { readFileSync } = require('fs'); -const { resolve } = require('path'); -const root = '$REPO_ROOT'; -const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', - 'server', 'cli']; -const names = []; -for (const d of dirs) { - try { - const pkg = JSON.parse(readFileSync(resolve(root, d, 'package.json'), 'utf8')); - if (!pkg.private) names.push(pkg.name); - } catch {} +info() { + echo "$@" } -console.log(names.join('\n')); -") - echo "" - echo " Promoting packages to @latest:" - while IFS= read -r pkg; do - if [ "$dry_run" = true ]; then - echo " [dry-run] npm dist-tag add ${pkg}@${NEW_VERSION} latest" - else - npm dist-tag add "${pkg}@${NEW_VERSION}" latest - echo " ✓ ${pkg}@${NEW_VERSION} → latest" - fi - done <<< "$PACKAGES" +fail() { + echo "Error: $*" >&2 + exit 1 +} - # Restore CLI dev package.json if present +restore_publish_artifacts() { if [ -f "$CLI_DIR/package.dev.json" ]; then mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" - echo " ✓ Restored workspace dependencies in cli/package.json" fi - # Remove the README copied for npm publishing - if [ -f "$CLI_DIR/README.md" ]; then - rm "$CLI_DIR/README.md" - fi - - # Remove temporary build artifacts + rm -f "$CLI_DIR/README.md" rm -rf "$REPO_ROOT/server/ui-dist" + for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do rm -rf "$REPO_ROOT/$pkg_dir/skills" done - - # Stage release files, commit, and tag - echo "" - echo " Committing and tagging v$NEW_VERSION..." - if [ "$dry_run" = true ]; then - echo " [dry-run] git add + commit + tag v$NEW_VERSION" - else - git add \ - .changeset/ \ - '**/CHANGELOG.md' \ - '**/package.json' \ - cli/src/index.ts - git commit -m "chore: release v$NEW_VERSION" - git tag "v$NEW_VERSION" - echo " ✓ Committed and tagged v$NEW_VERSION" - fi - - create_github_release "$NEW_VERSION" "$dry_run" - - echo "" - if [ "$dry_run" = true ]; then - echo "Dry run complete for promote v$NEW_VERSION." - echo " - Would promote all packages to @latest" - echo " - Would commit and tag v$NEW_VERSION" - echo " - Would create GitHub Release" - else - echo "Promoted all packages to @latest at v$NEW_VERSION" - echo "" - echo "Verify: npm view paperclipai@latest version" - echo "" - echo "To push:" - echo " git push && git push origin v$NEW_VERSION" - fi - exit 0 -fi - -# ── Step 1: Preflight checks ───────────────────────────────────────────────── - -echo "" -echo "==> Step 1/7: Preflight checks..." - -if [ "$dry_run" = false ]; then - if ! npm whoami &>/dev/null; then - echo "Error: Not logged in to npm. Run 'npm login' first." - exit 1 - fi - echo " ✓ Logged in to npm as $(npm whoami)" -fi - -if ! git -C "$REPO_ROOT" diff --quiet || ! git -C "$REPO_ROOT" diff --cached --quiet; then - echo "Error: Working tree has uncommitted changes. Commit or stash them first." - exit 1 -fi -echo " ✓ Working tree is clean" - -# ── Step 2: Auto-create changeset ──────────────────────────────────────────── - -echo "" -echo "==> Step 2/7: Creating changeset ($bump_type bump for all packages)..." - -# Get all publishable (non-private) package names -PACKAGES=$(node -e " -const { readdirSync, readFileSync } = require('fs'); -const { resolve } = require('path'); -const root = '$REPO_ROOT'; -const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); -const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', - 'server', 'cli']; -const names = []; -for (const d of dirs) { - try { - const pkg = JSON.parse(readFileSync(resolve(root, d, 'package.json'), 'utf8')); - if (!pkg.private) names.push(pkg.name); - } catch {} } -console.log(names.join('\n')); -") -# Write a changeset file -CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" +cleanup_release_state() { + restore_publish_artifacts + + rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" + + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + git -C "$REPO_ROOT" restore --source=HEAD --staged --worktree . + rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" + fi +} + +if [ "$cleanup_on_exit" = true ]; then + trap cleanup_release_state EXIT +fi + +set_cleanup_trap() { + cleanup_on_exit=true + trap cleanup_release_state EXIT +} + +require_clean_worktree() { + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + fail "working tree is not clean. Commit, stash, or remove changes before releasing." + fi +} + +require_npm_publish_auth() { + if [ "$dry_run" = true ]; then + return + fi + + if npm whoami >/dev/null 2>&1; then + info " ✓ Logged in to npm as $(npm whoami)" + return + fi + + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing" + return + fi + + fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow." +} + +list_public_package_info() { + node - "$REPO_ROOT" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const roots = ['packages', 'server', 'ui', 'cli']; +const seen = new Set(); +const rows = []; + +function walk(relDir) { + const absDir = path.join(root, relDir); + const pkgPath = path.join(absDir, 'package.json'); + + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.private) { + rows.push([relDir, pkg.name]); + } + return; + } + + if (!fs.existsSync(absDir)) { + return; + } + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + } +} + +for (const rel of roots) { + walk(rel); +} + +rows.sort((a, b) => a[0].localeCompare(b[0])); + +for (const [dir, name] of rows) { + const key = `${dir}\t${name}`; + if (seen.has(key)) continue; + seen.add(key); + process.stdout.write(`${dir}\t${name}\n`); +} +NODE +} + +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 +} + +replace_version_string() { + local from_version="$1" + local to_version="$2" + + node - "$REPO_ROOT" "$from_version" "$to_version" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const fromVersion = process.argv[3]; +const toVersion = process.argv[4]; + +const roots = ['packages', 'server', 'ui', 'cli']; +const targets = new Set(['package.json', 'CHANGELOG.md']); +const extraFiles = [path.join('cli', 'src', 'index.ts')]; + +function rewriteFile(filePath) { + if (!fs.existsSync(filePath)) return; + const current = fs.readFileSync(filePath, 'utf8'); + if (!current.includes(fromVersion)) return; + fs.writeFileSync(filePath, current.split(fromVersion).join(toVersion)); +} + +function walk(relDir) { + const absDir = path.join(root, relDir); + if (!fs.existsSync(absDir)) return; + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + continue; + } + + if (targets.has(entry.name)) { + rewriteFile(path.join(absDir, entry.name)); + } + } +} + +for (const rel of roots) { + walk(rel); +} + +for (const relFile of extraFiles) { + rewriteFile(path.join(root, relFile)); +} +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_PUBLISH_VERSION="$TARGET_STABLE_VERSION" + +if [ "$canary" = true ]; then + TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" +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)" + +if [ -z "$PUBLIC_PACKAGE_INFO" ]; then + fail "no public packages were found in the workspace." +fi + +info "" +info "==> Release plan" +info " Last stable tag: ${LAST_STABLE_TAG:-}" +info " Current stable version: $CURRENT_STABLE_VERSION" +if [ "$canary" = true ]; then + info " Target stable version: $TARGET_STABLE_VERSION" + info " Canary version: $TARGET_PUBLISH_VERSION" +else + info " Stable version: $TARGET_STABLE_VERSION" +fi + +info "" +info "==> Step 1/7: Preflight checks..." +require_clean_worktree +info " ✓ Working tree is clean" +require_npm_publish_auth + +if [ "$dry_run" = true ] || [ "$canary" = true ]; then + set_cleanup_trap +fi + +info "" +info "==> Step 2/7: Creating release changeset..." { echo "---" - while IFS= read -r pkg; do - echo "\"$pkg\": $bump_type" - done <<< "$PACKAGES" + while IFS= read -r pkg_name; do + [ -z "$pkg_name" ] && continue + echo "\"$pkg_name\": $bump_type" + done <<< "$PUBLIC_PACKAGE_NAMES" echo "---" echo "" - echo "Version bump ($bump_type)" -} > "$CHANGESET_FILE" + if [ "$canary" = true ]; then + echo "Canary release preparation for $TARGET_STABLE_VERSION" + else + echo "Stable release preparation for $TARGET_STABLE_VERSION" + fi +} > "$TEMP_CHANGESET_FILE" +info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages" -echo " ✓ Created changeset for $(echo "$PACKAGES" | wc -l | xargs) packages" - -# ── Step 3: Version packages ───────────────────────────────────────────────── - -echo "" -echo "==> Step 3/7: Running changeset version..." +info "" +info "==> Step 3/7: Versioning packages..." cd "$REPO_ROOT" +if [ "$canary" = true ]; then + npx changeset pre enter canary +fi npx changeset version -echo " ✓ Versions bumped and CHANGELOGs generated" -# Read the new version from the CLI package -NEW_VERSION=$(node -e "console.log(require('$CLI_DIR/package.json').version)") -echo " New version: $NEW_VERSION" - -# Update the version string in cli/src/index.ts -CURRENT_VERSION_IN_SRC=$(sed -n 's/.*\.version("\([^"]*\)".*/\1/p' "$CLI_DIR/src/index.ts" | head -1) -if [ -n "$CURRENT_VERSION_IN_SRC" ] && [ "$CURRENT_VERSION_IN_SRC" != "$NEW_VERSION" ]; then - sed -i '' "s/\.version(\"$CURRENT_VERSION_IN_SRC\")/\.version(\"$NEW_VERSION\")/" "$CLI_DIR/src/index.ts" - echo " ✓ Updated cli/src/index.ts version to $NEW_VERSION" +if [ "$canary" = true ]; then + BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0" + if [ "$TARGET_PUBLISH_VERSION" != "$BASE_CANARY_VERSION" ]; then + replace_version_string "$BASE_CANARY_VERSION" "$TARGET_PUBLISH_VERSION" + fi fi -# ── Step 4: Build packages ─────────────────────────────────────────────────── +VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")" +if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then + fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE." +fi +info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION" -echo "" -echo "==> Step 4/7: Building all packages..." +info "" +info "==> Step 4/7: Building workspace artifacts..." cd "$REPO_ROOT" - -# Build packages in dependency order (excluding CLI) -pnpm --filter @paperclipai/shared build -pnpm --filter @paperclipai/adapter-utils build -pnpm --filter @paperclipai/db build -pnpm --filter @paperclipai/adapter-claude-local build -pnpm --filter @paperclipai/adapter-codex-local build -pnpm --filter @paperclipai/adapter-opencode-local build -pnpm --filter @paperclipai/adapter-openclaw-gateway build -pnpm --filter @paperclipai/server build - -# Build UI and bundle into server package for static serving +pnpm build bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" - -# Bundle skills into packages that need them (adapters + server) for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do rm -rf "$REPO_ROOT/$pkg_dir/skills" cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills" done -echo " ✓ All packages built (including UI + skills)" +info " ✓ Workspace build complete" -# ── Step 5: Build CLI bundle ───────────────────────────────────────────────── - -echo "" -echo "==> Step 5/7: Building CLI bundle..." -cd "$REPO_ROOT" +info "" +info "==> Step 5/7: Building publishable CLI bundle..." "$REPO_ROOT/scripts/build-npm.sh" --skip-checks -echo " ✓ CLI bundled" - -# ── Step 6: Publish ────────────────────────────────────────────────────────── +info " ✓ CLI bundle ready" +info "" if [ "$dry_run" = true ]; then - echo "" - if [ "$canary" = true ]; then - echo "==> Step 6/7: Skipping publish (--dry-run, --canary)" - else - echo "==> Step 6/7: Skipping publish (--dry-run)" - fi - echo "" - echo " Preview what would be published:" - for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \ - server cli; do - echo " --- $dir ---" - cd "$REPO_ROOT/$dir" + info "==> Step 6/7: Previewing publish payloads (--dry-run)..." + while IFS= read -r pkg_dir; do + [ -z "$pkg_dir" ] && continue + info " --- $pkg_dir ---" + cd "$REPO_ROOT/$pkg_dir" npm pack --dry-run 2>&1 | tail -3 - done + done <<< "$PUBLIC_PACKAGE_DIRS" cd "$REPO_ROOT" if [ "$canary" = true ]; then - echo "" - echo " [dry-run] Would publish with: npx changeset publish --tag canary" + info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary" + else + info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi else - echo "" if [ "$canary" = true ]; then - echo "==> Step 6/7: Publishing to npm (canary)..." - cd "$REPO_ROOT" + info "==> Step 6/7: Publishing canary to npm..." npx changeset publish --tag canary - echo " ✓ Published all packages under @canary tag" + info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" else - echo "==> Step 6/7: Publishing to npm..." - cd "$REPO_ROOT" + info "==> Step 6/7: Publishing stable release to npm..." npx changeset publish - echo " ✓ Published all packages" + info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi fi -# ── Step 7: Restore CLI dev package.json and commit ────────────────────────── - -echo "" -if [ "$canary" = true ]; then - echo "==> Step 7/7: Skipping commit and tag (canary mode — promote later)..." +info "" +if [ "$dry_run" = true ]; then + info "==> Step 7/7: Cleaning up dry-run state..." + info " ✓ Dry run leaves the working tree unchanged" +elif [ "$canary" = true ]; then + info "==> Step 7/7: Cleaning up canary state..." + info " ✓ Canary state will be discarded after publish" else - echo "==> Step 7/7: Restoring dev package.json, committing, and tagging..." -fi -cd "$REPO_ROOT" + info "==> Step 7/7: Finalizing stable release commit..." + restore_publish_artifacts -# Restore the dev package.json (build-npm.sh backs it up) -if [ -f "$CLI_DIR/package.dev.json" ]; then - mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" - echo " ✓ Restored workspace dependencies in cli/package.json" + git -C "$REPO_ROOT" add -u .changeset packages server cli + if [ -f "$REPO_ROOT/releases/v${TARGET_STABLE_VERSION}.md" ]; then + git -C "$REPO_ROOT" add "releases/v${TARGET_STABLE_VERSION}.md" + fi + + git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION" + git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION" + info " ✓ Created commit and tag v$TARGET_STABLE_VERSION" fi -# Remove the README copied for npm publishing -if [ -f "$CLI_DIR/README.md" ]; then - rm "$CLI_DIR/README.md" -fi - -# Remove temporary build artifacts before committing (these are only needed during publish) -rm -rf "$REPO_ROOT/server/ui-dist" -for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do - rm -rf "$REPO_ROOT/$pkg_dir/skills" -done - -if [ "$canary" = false ]; then - # Stage only release-related files (avoid sweeping unrelated changes with -A) - git add \ - .changeset/ \ - '**/CHANGELOG.md' \ - '**/package.json' \ - cli/src/index.ts - git commit -m "chore: release v$NEW_VERSION" - git tag "v$NEW_VERSION" - echo " ✓ Committed and tagged v$NEW_VERSION" -fi - -if [ "$canary" = false ]; then - create_github_release "$NEW_VERSION" "$dry_run" -fi - -# ── Done ────────────────────────────────────────────────────────────────────── - -echo "" -if [ "$canary" = true ]; then - if [ "$dry_run" = true ]; then - echo "Dry run complete for canary v$NEW_VERSION." - echo " - Versions bumped, built, and previewed" - echo " - Dev package.json restored" - echo " - No commit or tag (canary mode)" - echo "" - echo "To actually publish canary, run:" - echo " ./scripts/release.sh $bump_type --canary" +info "" +if [ "$dry_run" = true ]; then + if [ "$canary" = true ]; then + info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}." else - echo "Published canary at v$NEW_VERSION" - echo "" - echo "Verify: npm view paperclipai@canary version" - echo "" - echo "To promote to latest:" - echo " ./scripts/release.sh --promote $NEW_VERSION" + info "Dry run complete for stable v${TARGET_STABLE_VERSION}." fi -elif [ "$dry_run" = true ]; then - echo "Dry run complete for v$NEW_VERSION." - echo " - Versions bumped, built, and previewed" - echo " - Dev package.json restored" - echo " - Commit and tag created (locally)" - echo " - Would create GitHub Release" - echo "" - echo "To actually publish, run:" - echo " ./scripts/release.sh $bump_type" +elif [ "$canary" = true ]; then + info "Published canary ${TARGET_PUBLISH_VERSION}." + info "Install with: npx paperclipai@canary onboard" + info "Stable version remains: $CURRENT_STABLE_VERSION" else - echo "Published all packages at v$NEW_VERSION" - echo "" - echo "To push:" - echo " git push && git push origin v$NEW_VERSION" - echo "" - echo "GitHub Release: https://github.com/cryppadotta/paperclip/releases/tag/v$NEW_VERSION" + info "Published stable v${TARGET_STABLE_VERSION}." + info "Next steps:" + info " git push origin HEAD:master --follow-tags" + info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" fi diff --git a/scripts/rollback-latest.sh b/scripts/rollback-latest.sh new file mode 100755 index 00000000..a00da984 --- /dev/null +++ b/scripts/rollback-latest.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +dry_run=false +version="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/rollback-latest.sh [--dry-run] + +Examples: + ./scripts/rollback-latest.sh 1.2.2 + ./scripts/rollback-latest.sh 1.2.2 --dry-run + +Notes: + - This repoints the npm dist-tag "latest" for every public package. + - It does not unpublish anything. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=true ;; + -h|--help) + usage + exit 0 + ;; + *) + if [ -n "$version" ]; then + echo "Error: only one version may be provided." >&2 + exit 1 + fi + version="$1" + ;; + esac + shift +done + +if [ -z "$version" ]; then + usage + exit 1 +fi + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be a stable semver like 1.2.2." >&2 + exit 1 +fi + +if [ "$dry_run" = false ] && ! npm whoami >/dev/null 2>&1; then + echo "Error: npm publish rights are required. Run 'npm login' first." >&2 + exit 1 +fi + +list_public_package_names() { + node - "$REPO_ROOT" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const roots = ['packages', 'server', 'ui', 'cli']; +const seen = new Set(); + +function walk(relDir) { + const absDir = path.join(root, relDir); + const pkgPath = path.join(absDir, 'package.json'); + + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.private && !seen.has(pkg.name)) { + seen.add(pkg.name); + process.stdout.write(`${pkg.name}\n`); + } + return; + } + + if (!fs.existsSync(absDir)) { + return; + } + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + } +} + +for (const rel of roots) { + walk(rel); +} +NODE +} + +package_names="$(list_public_package_names)" + +if [ -z "$package_names" ]; then + echo "Error: no public packages were found in the workspace." >&2 + exit 1 +fi + +while IFS= read -r package_name; do + [ -z "$package_name" ] && continue + if [ "$dry_run" = true ]; then + echo "[dry-run] npm dist-tag add ${package_name}@${version} latest" + else + npm dist-tag add "${package_name}@${version}" latest + echo "Updated latest -> ${package_name}@${version}" + fi +done <<< "$package_names" diff --git a/skills/release-changelog/SKILL.md b/skills/release-changelog/SKILL.md index d28fa931..b70b97f5 100644 --- a/skills/release-changelog/SKILL.md +++ b/skills/release-changelog/SKILL.md @@ -1,363 +1,140 @@ --- name: release-changelog description: > - Generate user-facing release changelogs for Paperclip. Reads git history, - merged PRs, and changeset files since the last release tag. Detects breaking - changes, categorizes changes, and outputs structured markdown to - releases/v{version}.md. Use when preparing a release or when asked to - generate a changelog. + Generate the stable Paperclip release changelog at releases/v{version}.md by + reading commits, changesets, and merged PR context since the last stable tag. --- # Release Changelog Skill -Generate a user-facing changelog for a new Paperclip release. This skill reads -the commit history, changeset files, and merged PRs since the last release tag, -detects breaking changes, categorizes everything, and writes a structured -release notes file. +Generate the user-facing changelog for the **stable** Paperclip release. -**Output:** `releases/v{version}.md` in the repo root. -**Review required:** Always present the draft for human sign-off before -finalizing. Never auto-publish. +Output: ---- +- `releases/v{version}.md` + +Important rule: + +- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md` ## Step 0 — Idempotency Check -Before generating anything, check if a changelog already exists for this version: +Before generating anything, check whether the file already exists: ```bash ls releases/v{version}.md 2>/dev/null ``` -**If the file already exists:** +If it exists: -1. Read the existing changelog and present it to the reviewer. -2. Ask: "A changelog for v{version} already exists. Do you want to (a) keep it - as-is, (b) regenerate from scratch, or (c) update specific sections?" -3. If the reviewer says keep it → **stop here**. Do not overwrite. This skill is - done. -4. If the reviewer says regenerate → back up the existing file to - `releases/v{version}.md.prev`, then proceed from Step 1. -5. If the reviewer says update → read the existing file, proceed through Steps - 1-4 to gather fresh data, then merge changes into the existing file rather - than replacing it wholesale. Preserve any manual edits the reviewer previously - made. +1. read it first +2. present it to the reviewer +3. ask whether to keep it, regenerate it, or update specific sections +4. never overwrite it silently -**If the file does not exist:** Proceed normally from Step 1. +## Step 1 — Determine the Stable Range -**Critical rule:** This skill NEVER triggers a version bump. It only reads git -history and writes a markdown file. The `release.sh` script is the only thing -that bumps versions, and it is called separately by the `release` coordination -skill. Running this skill multiple times is always safe — worst case it -overwrites a draft changelog (with reviewer permission). - ---- - -## Step 1 — Determine the Release Range - -Find the last release tag and the planned version: +Find the last stable tag: ```bash -# Last release tag (most recent semver tag) -git tag --sort=-version:refname | head -1 -# e.g. v0.2.7 - -# All commits since that tag -git log v0.2.7..HEAD --oneline --no-merges +git tag --list 'v*' --sort=-version:refname | head -1 +git log v{last}..HEAD --oneline --no-merges ``` -If no tag exists yet, use the initial commit as the base. +The planned stable version comes from one of: -The new version number comes from one of: -- An explicit argument (e.g. "generate changelog for v0.3.0") -- The bump type (patch/minor/major) applied to the last tag -- The version already set in `cli/package.json` if `scripts/release.sh` has been run +- an explicit maintainer request +- the chosen bump type applied to the last stable tag +- the release plan already agreed in `doc/RELEASING.md` ---- +Do not derive the changelog version from a canary tag or prerelease suffix. -## Step 2 — Gather Raw Change Data +## Step 2 — Gather the Raw Inputs -Collect changes from three sources, in priority order: +Collect release data from: -### 2a. Git Commits +1. git commits since the last stable tag +2. `.changeset/*.md` files +3. merged PRs via `gh` when available + +Useful commands: ```bash git log v{last}..HEAD --oneline --no-merges -git log v{last}..HEAD --format="%H %s" --no-merges # full SHAs for file diffs -``` - -### 2b. Changeset Files - -Look for unconsumed changesets in `.changeset/`: - -```bash +git log v{last}..HEAD --format="%H %s" --no-merges ls .changeset/*.md | grep -v README.md -``` - -Each changeset file has YAML frontmatter with package names and bump types -(`patch`, `minor`, `major`), followed by a description. Parse these — the bump -type is a strong categorization signal, and the description may contain -user-facing summaries. - -### 2c. Merged PRs (when available) - -If GitHub access is available via `gh`: - -```bash gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels ``` -PR titles and bodies are often the best source of user-facing descriptions. -Prefer PR descriptions over raw commit messages when both are available. - ---- - ## Step 3 — Detect Breaking Changes -Scan for breaking changes using these signals. **Any match flags the release as -containing breaking changes**, which affects version bump requirements and -changelog structure. +Look for: -### 3a. Migration Files +- destructive migrations +- removed or changed API fields/endpoints +- renamed or removed config keys +- `major` changesets +- `BREAKING:` or `BREAKING CHANGE:` commit signals -Check for new migration files since the last tag: +Key commands: ```bash git diff --name-only v{last}..HEAD -- packages/db/src/migrations/ -``` - -- **New migration files exist** = DB migration required in upgrade. -- Inspect migration content: look for `DROP`, `ALTER ... DROP`, `RENAME` to - distinguish destructive vs. additive migrations. -- Additive-only migrations (new tables, new nullable columns, new indexes) are - safe but should still be mentioned. -- Destructive migrations (column drops, type changes, table drops) = breaking. - -### 3b. Schema Changes - -```bash git diff v{last}..HEAD -- packages/db/src/schema/ -``` - -Look for: -- Removed or renamed columns/tables -- Changed column types -- Removed default values or nullable constraints -- These indicate breaking DB changes even if no explicit migration file exists - -### 3c. API Route Changes - -```bash git diff v{last}..HEAD -- server/src/routes/ server/src/api/ +git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true ``` -Look for: -- Removed endpoints -- Changed request/response shapes (removed fields, type changes) -- Changed authentication requirements +If the requested bump is lower than the minimum required bump, flag that before the release proceeds. -### 3d. Config Changes +## Step 4 — Categorize for Users -```bash -git diff v{last}..HEAD -- cli/src/config/ packages/*/src/*config* -``` +Use these stable changelog sections: -Look for renamed, removed, or restructured configuration keys. +- `Breaking Changes` +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed -### 3e. Changeset Severity +Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users. -Any `.changeset/*.md` file with a `major` bump = explicitly flagged breaking. +Guidelines: -### 3f. Commit Conventions +- group related commits into one user-facing entry +- write from the user perspective +- keep highlights short and concrete +- spell out upgrade actions for breaking changes -Scan commit messages for: -- `BREAKING:` or `BREAKING CHANGE:` prefix -- `!` after the type in conventional commits (e.g. `feat!:`, `fix!:`) +## Step 5 — Write the File -### Version Bump Rules - -| Condition | Minimum Bump | -|---|---| -| Destructive migration (DROP, RENAME) | `major` | -| Removed API endpoints or fields | `major` | -| Any `major` changeset or `BREAKING:` commit | `major` | -| New (additive) migration | `minor` | -| New features (`feat:` commits, `minor` changesets) | `minor` | -| Bug fixes only | `patch` | - -If the planned bump is lower than the minimum required, **warn the reviewer** -and recommend the correct bump level. - ---- - -## Step 4 — Categorize Changes - -Assign every meaningful change to one of these categories: - -| Category | What Goes Here | Shows in User Notes? | -|---|---|---| -| **Breaking Changes** | Anything requiring user action to upgrade | Yes (top, with warning) | -| **Highlights** | New user-visible features, major behavioral changes | Yes (with 1-2 sentence descriptions) | -| **Improvements** | Enhancements to existing features | Yes (bullet list) | -| **Fixes** | Bug fixes | Yes (bullet list) | -| **Internal** | Refactoring, deps, CI, tests, docs | No (dev changelog only) | - -### Categorization Heuristics - -Use these signals to auto-categorize. When signals conflict, prefer the -higher-visibility category and flag for human review. - -| Signal | Category | -|---|---| -| Commit touches migration files, schema changes | Breaking Change (if destructive) | -| Changeset marked `major` | Breaking Change | -| Commit message has `BREAKING:` or `!:` | Breaking Change | -| New UI components, new routes, new API endpoints | Highlight | -| Commit message starts with `feat:` or `add:` | Highlight or Improvement | -| Changeset marked `minor` | Highlight | -| Commit message starts with `fix:` or `bug:` | Fix | -| Changeset marked `patch` | Fix or Improvement | -| Commit message starts with `chore:`, `refactor:`, `ci:`, `test:`, `docs:` | Internal | -| PR has detailed body with user-facing description | Use PR body as the description | - -### Writing Good Descriptions - -- **Highlights** get 1-2 sentence descriptions explaining the user benefit. - Write from the user's perspective ("You can now..." not "Added a component that..."). -- **Improvements and Fixes** are concise bullet points. -- **Breaking Changes** get detailed descriptions including what changed, - why, and what the user needs to do. -- Group related commits into a single changelog entry. Five commits implementing - one feature = one Highlight entry, not five bullets. -- Omit purely internal changes from user-facing notes entirely. - ---- - -## Step 5 — Write the Changelog - -Output the changelog to `releases/v{version}.md` using this template: +Template: ```markdown # v{version} > Released: {YYYY-MM-DD} -{If breaking changes detected, include this section:} - ## Breaking Changes -> **Action required before upgrading.** Read the Upgrade Guide below. - -- **{Breaking change title}** — {What changed and why. What the user needs to do.} - ## Highlights -- **{Feature name}** — {1-2 sentence description of what it does and why it matters.} - ## Improvements -- {Concise description of improvement} - ## Fixes -- {Concise description of fix} - ---- - -{If breaking changes detected, include this section:} - ## Upgrade Guide - -### Before You Update - -1. **Back up your database.** - - SQLite: `cp paperclip.db paperclip.db.backup` - - Postgres: `pg_dump -Fc paperclip > paperclip-pre-{version}.dump` -2. **Note your current version:** `paperclip --version` - -### After Updating - -{Specific steps: run migrations, update configs, etc.} - -### Rolling Back - -If something goes wrong: -1. Restore your database backup -2. `npm install @paperclipai/server@{previous-version}` ``` -### Template Rules +Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist. -- Omit any empty section entirely (don't show "## Fixes" with no bullets). -- The Breaking Changes section always comes first when present. -- The Upgrade Guide always comes last when present. -- Use `**bold**` for feature/change names, regular text for descriptions. -- Keep the entire changelog scannable — a busy user should get the gist from - headings and bold text alone. +## Step 6 — Review Before Release ---- +Before handing it off: -## Step 6 — Present for Review +1. confirm the heading is the stable version only +2. confirm there is no `-canary` language in the title or filename +3. confirm any breaking changes have an upgrade path +4. present the draft for human sign-off -After generating the draft: - -1. **Show the full changelog** to the reviewer (CTO or whoever triggered the release). -2. **Flag ambiguous items** — commits you weren't sure how to categorize, or - items that might be breaking but aren't clearly signaled. -3. **Flag version bump mismatches** — if the planned bump is lower than what - the changes warrant. -4. **Wait for approval** before considering the changelog final. - -If the reviewer requests edits, update `releases/v{version}.md` accordingly. - -Do not proceed to publishing, website updates, or social announcements. Those -are handled by the `release` coordination skill (separate from this one). - ---- - -## Directory Convention - -Release changelogs live in `releases/` at the repo root: - -``` -releases/ - v0.2.7.md - v0.3.0.md - ... -``` - -Each file is named `v{version}.md` matching the git tag. This directory is -committed to the repo and serves as the source of truth for release history. - -The `releases/` directory should be created with a `.gitkeep` if it doesn't -exist yet. - ---- - -## Quick Reference - -```bash -# Full workflow summary: - -# 1. Find last tag -LAST_TAG=$(git tag --sort=-version:refname | head -1) - -# 2. Commits since last tag -git log $LAST_TAG..HEAD --oneline --no-merges - -# 3. Files changed (for breaking change detection) -git diff --name-only $LAST_TAG..HEAD - -# 4. Migration changes specifically -git diff --name-only $LAST_TAG..HEAD -- packages/db/src/migrations/ - -# 5. Schema changes -git diff $LAST_TAG..HEAD -- packages/db/src/schema/ - -# 6. Unconsumed changesets -ls .changeset/*.md | grep -v README.md - -# 7. Merged PRs (if gh available) -gh pr list --state merged --search "merged:>=$(git log -1 --format=%aI $LAST_TAG)" \ - --json number,title,body,labels -``` +This skill never publishes anything. It only prepares the stable changelog artifact. diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 4c91fffd..65468704 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -1,402 +1,234 @@ --- name: release description: > - Coordinate a full Paperclip release across engineering, website publishing, - and social announcement. Use when CTO/CEO requests "do a release" or - "release vX.Y.Z". Runs pre-flight checks, generates changelog via - release-changelog, executes npm release, creates cross-project follow-up - tasks, and posts a release wrap-up. + Coordinate a full Paperclip release across engineering verification, npm, + GitHub, website publishing, and announcement follow-up. Use when leadership + asks to ship a release, not merely to discuss version bumps. --- # Release Coordination Skill -Run the full Paperclip release process as an organizational workflow, not just -an npm publish. +Run the full Paperclip release as a maintainer workflow, not just an npm publish. This skill coordinates: -- User-facing changelog generation (`release-changelog` skill) -- Canary publish to npm (`scripts/release.sh --canary`) -- Docker smoke test of the canary (`scripts/docker-onboard-smoke.sh`) -- Promotion to `latest` after canary is verified -- Website publishing task creation -- CMO announcement task creation -- Final release summary with links ---- +- stable changelog drafting via `release-changelog` +- prerelease canary publishing via `scripts/release.sh --canary` +- Docker smoke testing via `scripts/docker-onboard-smoke.sh` +- stable publishing via `scripts/release.sh` +- pushing the release commit and tag +- GitHub Release creation via `scripts/create-github-release.sh` +- website / announcement follow-up tasks ## Trigger Use this skill when leadership asks for: -- "do a release" -- "release {patch|minor|major}" -- "release vX.Y.Z" ---- +- "do a release" +- "ship the next patch/minor/major" +- "release vX.Y.Z" ## Preconditions Before proceeding, verify all of the following: 1. `skills/release-changelog/SKILL.md` exists and is usable. -2. The `release-changelog` dependency work is complete/reviewed before running this flow. -3. App repo working tree is clean. -4. There are commits since the last release tag. -5. You have release permissions (`npm whoami` succeeds for real publish). -6. If running via Paperclip, you have issue context for posting status updates. +2. The repo working tree is clean, including untracked files. +3. There are commits since the last stable tag. +4. The release SHA has passed the verification gate or is about to. +5. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. +6. If running through Paperclip, you have issue context for status updates and follow-up task creation. If any precondition fails, stop and report the blocker. ---- - ## Inputs Collect these inputs up front: -- Release request source issue (if in Paperclip) -- Requested bump (`patch|minor|major`) or explicit version (`vX.Y.Z`) -- Whether this run is dry-run or live publish -- Company/project context for follow-up issue creation +- requested bump: `patch`, `minor`, or `major` +- whether this run is a dry run or live release +- whether the release is being run locally or from GitHub Actions +- release issue / company context for website and announcement follow-up ---- +## Step 0 — Release Model -## Step 0 — Idempotency Guards +Paperclip now uses this release model: -Each step in this skill is designed to be safely re-runnable. Before executing -any step, check whether it has already been completed: +1. Draft the **stable** changelog as `releases/vX.Y.Z.md` +2. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0` +3. Smoke test the canary via Docker +4. Publish the stable version `X.Y.Z` +5. Push the release commit and tag +6. Create the GitHub Release +7. Complete website and announcement surfaces -| Step | How to Check | If Already Done | -|---|---|---| -| Changelog | `releases/v{version}.md` exists | Read it, ask reviewer to confirm or update. Do NOT regenerate without asking. | -| Canary publish | `npm view paperclipai@{version}` succeeds | Skip canary publish. Proceed to smoke test. | -| Smoke test | Manual or scripted verification | If canary already verified, proceed to promote. | -| Promote | `git tag v{version}` exists | Skip promotion entirely. A tag means the version is already promoted to latest. | -| Website task | Search Paperclip issues for "Publish release notes for v{version}" | Skip creation. Link the existing task. | -| CMO task | Search Paperclip issues for "release announcement tweet for v{version}" | Skip creation. Link the existing task. | +Critical consequence: -**The golden rule:** If a git tag `v{version}` already exists, the release is -fully promoted. Only post-publish tasks (website, CMO, wrap-up) should proceed. -If the version exists on npm but there's no git tag, the canary was published but -not yet promoted — resume from smoke test. +- Canaries do **not** use promote-by-dist-tag anymore. +- The changelog remains stable-only. Do not create `releases/vX.Y.Z-canary.N.md`. -**Iterating on changelogs:** You can re-run this skill with an existing changelog -to refine it _before_ the npm publish step. The `release-changelog` skill has -its own idempotency check and will ask the reviewer what to do with an existing -file. This is the expected workflow for iterating on release notes. +## Step 1 — Decide the Stable Version ---- - -## Step 1 - Pre-flight and Version Decision - -Run pre-flight in the App repo root: +Use the last stable tag as the base: ```bash -LAST_TAG=$(git tag --sort=-version:refname | head -1) -git diff --quiet && git diff --cached --quiet -git log "${LAST_TAG}..HEAD" --oneline --no-merges | head -50 -``` - -Then detect minimum required bump: - -```bash -# migrations +LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) +git log "${LAST_TAG}..HEAD" --oneline --no-merges git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ - -# schema deltas git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ - -# breaking commit conventions git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true ``` Bump policy: -- Destructive migration/API removal/major changeset/breaking commit -> `major` -- Additive migrations or clear new features -> at least `minor` -- Fixes-only -> `patch` -If requested bump is lower than required minimum, escalate bump and explain why. +- destructive migrations, removed APIs, breaking config changes -> `major` +- additive migrations or clearly user-visible features -> at least `minor` +- fixes only -> `patch` ---- +If the requested bump is too low, escalate it and explain why. -## Step 2 - Generate Changelog Draft +## Step 2 — Draft the Stable Changelog -First, check if `releases/v{version}.md` already exists. If it does, the -`release-changelog` skill will detect this and ask the reviewer whether to keep, -regenerate, or update it. **Do not silently overwrite an existing changelog.** +Invoke `release-changelog` and generate: -Invoke the `release-changelog` skill and produce: -- `releases/v{version}.md` -- Sections ordered as: Breaking Changes (if any), Highlights, Improvements, Fixes, Upgrade Guide (if any) +- `releases/vX.Y.Z.md` -Required behavior: -- Present the draft for human review. -- Flag ambiguous categorization items. -- Flag bump mismatches before publish. -- Do not publish until reviewer confirms. +Rules: ---- +- review the draft with a human before publish +- preserve manual edits if the file already exists +- keep the heading and filename stable-only, for example `v1.2.3` +- do not create a separate canary changelog file -## Step 3 — Publish Canary +## Step 3 — Verify the Release SHA -The canary is the gatekeeper: every release goes to npm as a canary first. The -`latest` tag is never touched until the canary passes smoke testing. - -**Idempotency check:** Before publishing, check if this version already exists -on npm: +Run the standard gate: ```bash -# Check if canary is already published -npm view paperclipai@{version} version 2>/dev/null && echo "ALREADY_PUBLISHED" || echo "NOT_PUBLISHED" - -# Also check git tag -git tag -l "v{version}" +pnpm -r typecheck +pnpm test:run +pnpm build ``` -- If a git tag exists → the release is already fully promoted. Skip to Step 6. -- If the version exists on npm but no git tag → canary was published but not yet - promoted. Skip to Step 4 (smoke test). -- If neither exists → proceed with canary publish. +If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes. -### Publishing the canary +## Step 4 — Publish a Canary -Use `release.sh` with the `--canary` flag (see script changes below): +Run: ```bash -# Dry run first ./scripts/release.sh {patch|minor|major} --canary --dry-run - -# Publish canary (after dry-run review) ./scripts/release.sh {patch|minor|major} --canary ``` -This publishes all packages to npm with the `canary` dist-tag. The `latest` tag -is **not** updated. Users running `npx paperclipai onboard` still get the -previous stable version. +What this means: -After publish, verify the canary is accessible: +- npm receives `X.Y.Z-canary.N` under dist-tag `canary` +- `latest` remains unchanged +- no git tag is created +- the script cleans the working tree afterward + +After publish, verify: ```bash npm view paperclipai@canary version -# Should show the new version ``` -**How `--canary` works in release.sh:** -- Steps 1-5 are the same (preflight, changeset, version, build, CLI bundle) -- Step 6 uses `npx changeset publish --tag canary` instead of `npx changeset publish` -- Step 7 does NOT commit or tag — the commit and tag happen later in the promote - step, only after smoke testing passes +The user install path is: -**Script changes required:** Add `--canary` support to `scripts/release.sh`: -- Parse `--canary` flag alongside `--dry-run` -- When `--canary`: pass `--tag canary` to `changeset publish` -- When `--canary`: skip the git commit and tag step (Step 7) -- When NOT `--canary`: behavior is unchanged (backwards compatible) +```bash +npx paperclipai@canary onboard +``` ---- +## Step 5 — Smoke Test the Canary -## Step 4 — Smoke Test the Canary - -Run the canary in a clean Docker environment to verify `npx paperclipai onboard` -works end-to-end. - -### Automated smoke test - -Use the existing Docker smoke test infrastructure with the canary version: +Run: ```bash PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ``` -This builds a clean Ubuntu container, installs `paperclipai@canary` via npx, and -runs the onboarding flow. The UI is accessible at `http://localhost:3131`. +Confirm: -### What to verify +1. install succeeds +2. onboarding completes +3. server boots +4. UI loads +5. basic company/dashboard flow works -At minimum, confirm: +If smoke testing fails: -1. **Container starts** — no npm install errors, no missing dependencies -2. **Onboarding completes** — the wizard runs through without crashes -3. **Server boots** — UI is accessible at the expected port -4. **Basic operations** — can create a company, view the dashboard +- stop the stable release +- fix the issue +- publish another canary +- repeat the smoke test -For a more thorough check (stretch goal — can be automated later): +Each retry should create a higher canary ordinal, while the stable target version can stay the same. -5. **Browser automation** — script Playwright/Puppeteer to walk through onboard - in the Docker container's browser and verify key pages render +## Step 6 — Publish Stable -### If smoke test fails - -- Do NOT promote the canary. -- Fix the issue, publish a new canary (re-run Step 3 — idempotency guards allow - this since there's no git tag yet). -- Re-run the smoke test. - -### If smoke test passes - -Proceed to Step 5 (promote). - ---- - -## Step 5 — Promote Canary to Latest - -Once the canary passes smoke testing, promote it to `latest` so that -`npx paperclipai onboard` picks up the new version. - -### Promote on npm +Once the SHA is vetted, run: ```bash -# For each published package, move the dist-tag from canary to latest -npm dist-tag add paperclipai@{version} latest -npm dist-tag add @paperclipai/server@{version} latest -npm dist-tag add @paperclipai/cli@{version} latest -npm dist-tag add @paperclipai/shared@{version} latest -npm dist-tag add @paperclipai/db@{version} latest -npm dist-tag add @paperclipai/adapter-utils@{version} latest -npm dist-tag add @paperclipai/adapter-claude-local@{version} latest -npm dist-tag add @paperclipai/adapter-codex-local@{version} latest -npm dist-tag add @paperclipai/adapter-openclaw-gateway@{version} latest +./scripts/release.sh {patch|minor|major} --dry-run +./scripts/release.sh {patch|minor|major} ``` -**Script option:** Add `./scripts/release.sh --promote {version}` to automate -the dist-tag promotion for all packages. +Stable publish does this: -### Commit and tag +- publishes `X.Y.Z` to npm under `latest` +- creates the local release commit +- creates the local git tag `vX.Y.Z` -After promotion, finalize in git (this is what `release.sh` Step 7 normally -does, but was deferred during canary publish): +Stable publish does **not** push the release for you. + +## Step 7 — Push and Create GitHub Release + +After stable publish succeeds: ```bash -git add . -git commit -m "chore: release v{version}" -git tag "v{version}" +git push origin HEAD:master --follow-tags +./scripts/create-github-release.sh X.Y.Z ``` -### Verify promotion +Use the stable changelog file as the GitHub Release notes source. -```bash -npm view paperclipai@latest version -# Should now show the new version +## Step 8 — Finish the Other Surfaces -# Final sanity check -npx --yes paperclipai@latest --version -``` +Create or verify follow-up work for: ---- +- website changelog publishing +- launch post / social announcement +- any release summary in Paperclip issue context -## Step 6 - Create Cross-Project Follow-up Tasks - -**Idempotency check:** Before creating tasks, search for existing ones: - -``` -GET /api/companies/{companyId}/issues?q=release+notes+v{version} -GET /api/companies/{companyId}/issues?q=announcement+tweet+v{version} -``` - -If matching tasks already exist (check title contains the version), skip -creation and link the existing tasks instead. Do not create duplicates. - -Create at least two tasks in Paperclip (only if they don't already exist): - -1. Website task: publish changelog for `v{version}` -2. CMO task: draft announcement tweet for `v{version}` - -When creating tasks: -- Set `parentId` to the release issue id. -- Carry over `goalId` from the parent issue when present. -- Include `billingCode` for cross-team work when required by company policy. -- Mark website task `high` priority if release has breaking changes. - -Suggested payloads: - -```json -POST /api/companies/{companyId}/issues -{ - "projectId": "{websiteProjectId}", - "parentId": "{releaseIssueId}", - "goalId": "{goalId-or-null}", - "billingCode": "{billingCode-or-null}", - "title": "Publish release notes for v{version}", - "priority": "medium", - "status": "todo", - "description": "Publish /changelog entry for v{version}. Include full markdown from releases/v{version}.md and prominent upgrade guide if breaking changes exist." -} -``` - -```json -POST /api/companies/{companyId}/issues -{ - "projectId": "{workspaceProjectId}", - "parentId": "{releaseIssueId}", - "goalId": "{goalId-or-null}", - "billingCode": "{billingCode-or-null}", - "title": "Draft release announcement tweet for v{version}", - "priority": "medium", - "status": "todo", - "description": "Draft launch tweet with top 1-2 highlights, version number, and changelog URL. If breaking changes exist, include an explicit upgrade-guide callout." -} -``` - ---- - -## Step 7 - Wrap Up the Release Issue - -Post a concise markdown update linking: -- Release issue -- Changelog file (`releases/v{version}.md`) -- npm package URL (both `@canary` and `@latest` after promotion) -- Canary smoke test result (pass/fail, what was tested) -- Website task -- CMO task -- Final changelog URL (once website publishes) -- Tweet URL (once published) - -Completion rules: -- Keep issue `in_progress` until canary is promoted AND website + social tasks - are done. -- Mark `done` only when all required artifacts are published and linked. -- If waiting on another team, keep open with clear owner and next action. - ---- - -## Release Flow Summary - -The full release lifecycle is now: - -``` -1. Generate changelog → releases/v{version}.md (review + iterate) -2. Publish canary → npm @canary dist-tag (latest untouched) -3. Smoke test canary → Docker clean install verification -4. Promote to latest → npm @latest dist-tag + git tag + commit -5. Create follow-up tasks → website changelog + CMO tweet -6. Wrap up → link everything, close issue -``` - -At any point you can re-enter the flow — idempotency guards detect which steps -are already done and skip them. The changelog can be iterated before or after -canary publish. The canary can be re-published if the smoke test reveals issues -(just fix + re-run Step 3). Only after smoke testing passes does `latest` get -updated. - ---- - -## Paperclip API Notes (When Running in Agent Context) - -Use: -- `GET /api/companies/{companyId}/projects` to resolve website/workspace project IDs. -- `POST /api/companies/{companyId}/issues` to create follow-up tasks. -- `PATCH /api/issues/{issueId}` with comments for release progress. - -For issue-modifying calls, include: -- `Authorization: Bearer $PAPERCLIP_API_KEY` -- `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` - ---- +These should reference the stable release, not the canary. ## Failure Handling -If blocked, update the release issue explicitly with: -- what failed -- exact blocker -- who must act next -- whether any release artifacts were partially published +If the canary is bad: -Never silently fail mid-release. +- publish another canary, do not ship stable + +If stable npm publish succeeds but push or GitHub release creation fails: + +- fix the git/GitHub issue immediately from the same checkout +- do not republish the same version + +If `latest` is bad after stable publish: + +```bash +./scripts/rollback-latest.sh +``` + +Then fix forward with a new patch release. + +## Output + +When the skill completes, provide: + +- stable version and, if relevant, the final canary version tested +- verification status +- npm status +- git tag / GitHub Release status +- website / announcement follow-up status +- rollback recommendation if anything is still partially complete