chore: formalize release workflow
This commit is contained in:
132
.github/workflows/release.yml
vendored
Normal file
132
.github/workflows/release.yml
vendored
Normal file
@@ -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"
|
||||
@@ -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<version>
|
||||
```
|
||||
- `@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 <patch|minor|major|X.Y.Z>
|
||||
```
|
||||
|
||||
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 <stable-version>
|
||||
```
|
||||
|
||||
### 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 <type>` | One-command bump + build + publish + commit + tag |
|
||||
| `build:npm` | `pnpm build:npm` | Full build (check + typecheck + bundle + package.json) |
|
||||
| `version:bump` | `pnpm version:bump <type>` | 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)
|
||||
|
||||
437
doc/RELEASING.md
Normal file
437
doc/RELEASING.md
Normal file
@@ -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 <patch|minor|major> --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 <patch|minor|major>
|
||||
```
|
||||
|
||||
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 <last-good-version>
|
||||
```
|
||||
|
||||
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 <version>`
|
||||
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
|
||||
@@ -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",
|
||||
|
||||
86
scripts/create-github-release.sh
Executable file
86
scripts/create-github-release.sh
Executable file
@@ -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 <version> [--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
|
||||
@@ -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 <patch|minor|major> [--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 <patch|minor|major> [--dry-run] [--canary]"
|
||||
echo " $0 --promote <version> [--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:-<none>}"
|
||||
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
|
||||
|
||||
111
scripts/rollback-latest.sh
Executable file
111
scripts/rollback-latest.sh
Executable file
@@ -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 <stable-version> [--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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <last-good-version>
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user