Merge branch 'master' into fix/windows-command-compat

This commit is contained in:
Dotta
2026-03-09 11:25:18 -05:00
committed by GitHub
32 changed files with 2540 additions and 1332 deletions

44
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: E2E Tests
on:
workflow_dispatch:
inputs:
skip_llm:
description: "Skip LLM-dependent assertions (default: true)"
type: boolean
default: true
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: npx playwright install --with-deps chromium
- name: Run e2e tests
run: pnpm run test:e2e
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: |
tests/e2e/playwright-report/
tests/e2e/test-results/
retention-days: 14

View File

@@ -32,6 +32,7 @@ jobs:
node-version: 20
- name: Block manual lockfile edits
if: github.head_ref != 'chore/refresh-lockfile'
run: |
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then

View File

@@ -11,11 +11,12 @@ concurrency:
cancel-in-progress: false
jobs:
refresh_and_verify:
refresh:
runs-on: ubuntu-latest
timeout-minutes: 25
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
@@ -40,6 +41,7 @@ jobs:
run: |
changed="$(git status --porcelain)"
if [ -z "$changed" ]; then
echo "Lockfile is already up to date."
exit 0
fi
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
@@ -48,29 +50,32 @@ jobs:
exit 1
fi
- name: Commit refreshed lockfile
- name: Create or update pull request
env:
GH_TOKEN: ${{ github.token }}
run: |
if git diff --quiet -- pnpm-lock.yaml; then
echo "Lockfile unchanged, nothing to do."
exit 0
fi
BRANCH="chore/refresh-lockfile"
git config user.name "lockfile-bot"
git config user.email "lockfile-bot@users.noreply.github.com"
git checkout -B "$BRANCH"
git add pnpm-lock.yaml
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
git push || {
echo "Push failed because master moved during lockfile refresh."
echo "A later refresh run should recompute the lockfile from the newer master state."
exit 1
}
git push --force origin "$BRANCH"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
# Create PR if one doesn't already exist
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
if [ -z "$existing" ]; then
gh pr create \
--head "$BRANCH" \
--title "chore(lockfile): refresh pnpm-lock.yaml" \
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
echo "Created new PR."
else
echo "PR #$existing already exists, branch updated via force push."
fi

132
.github/workflows/release.yml vendored Normal file
View 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 --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 --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"

4
.gitignore vendored
View File

@@ -37,3 +37,7 @@ tmp/
.vscode/
.claude/settings.local.json
.paperclip-local/
# Playwright
tests/e2e/test-results/
tests/e2e/playwright-report/

View File

@@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) {
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: {
provider: "local_disk",

View File

@@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
},
auth: {
baseUrlMode: "auto",
disableSignUp: false,
},
storage: defaultStorageConfig(),
secrets: defaultSecretsConfig(),

View File

@@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): {
},
auth: {
baseUrlMode: authBaseUrlMode,
disableSignUp: false,
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
},
storage: {

View File

@@ -113,7 +113,7 @@ export async function promptServer(opts?: {
}
const port = Number(portStr) || 3100;
let auth: AuthConfig = { baseUrlMode: "auto" };
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
if (deploymentMode === "authenticated" && exposure === "public") {
const urlInput = await p.text({
message: "Public base URL",
@@ -139,11 +139,13 @@ export async function promptServer(opts?: {
}
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
};
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
auth = {
baseUrlMode: "explicit",
disableSignUp: false,
publicBaseUrl: currentAuth.publicBaseUrl,
};
}

View File

@@ -1,196 +1,120 @@
# 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-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push
```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)

526
doc/RELEASING.md Normal file
View File

@@ -0,0 +1,526 @@
# 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. Confirm master already has the CI-owned lockfile refresh merged
# If package manifests changed recently, wait for the refresh-lockfile PR first.
# 1. Preflight the canary candidate
./scripts/release-preflight.sh canary patch
# 2. Draft or update the stable changelog for the intended stable version
VERSION=0.2.8
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
# 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
- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N`
### Stable release
Use this only after the canary SHA is good enough to become the public default.
```bash
# 0. Confirm master already has the CI-owned lockfile refresh merged
# If package manifests changed recently, wait for the refresh-lockfile PR first.
# 1. Start from the vetted commit
git checkout master
git pull
# 2. Preflight the stable candidate
./scripts/release-preflight.sh stable patch
# 3. Confirm the stable changelog exists
VERSION=0.2.8
ls "releases/v${VERSION}.md"
# 4. Preview the stable publish
./scripts/release.sh patch --dry-run
# 5. Publish the stable release to npm and create the local release commit + tag
./scripts/release.sh patch
# 6. Push the release commit and tag
git push public-gh HEAD:master --follow-tags
# 7. 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.
### Standalone onboarding smoke
You already have a script for isolated onboarding verification:
```bash
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
```
This is the best existing fit when you want:
- a standalone Paperclip data dir
- a dedicated host port
- an end-to-end `npx paperclipai ... onboard` check
If you want to exercise onboarding from a fresh local checkout rather than npm, use:
```bash
./scripts/clean-onboard-git.sh
```
That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing.
If you want to exercise onboarding from the current committed ref in your local repo, use:
```bash
./scripts/clean-onboard-ref.sh
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
```
This uses the current committed `HEAD` in a detached temp worktree. It does **not** include uncommitted local edits.
### 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
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`
- [ ] 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.
The release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml) installs with `pnpm install --frozen-lockfile`. That is intentional. Releases must use the exact dependency graph already committed on `master`; if manifests changed and the CI-owned lockfile refresh has not landed yet, the release should fail until that prerequisite is merged.
For release work, prefer:
```bash
./scripts/release-preflight.sh canary <patch|minor|major>
./scripts/release-preflight.sh stable <patch|minor|major>
```
That script runs the verification gate and prints the computed target versions before you publish anything.
## 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.
Concrete example:
- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0`
- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version
## Changelog Policy
The maintainer changelog source of truth is:
- `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
Run preflight first:
```bash
./scripts/release-preflight.sh canary <patch|minor|major>
# or
./scripts/release-preflight.sh stable <patch|minor|major>
```
That command:
- verifies the worktree is clean, including untracked files
- shows the last stable tag and computed next versions
- shows the commit range since the last stable tag
- highlights migration and breaking-change signals
- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build`
If you want the raw inputs separately, review the range since the last stable tag:
```bash
LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1)
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
VERSION=X.Y.Z
claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
```
This is deliberate. The release notes should describe the stable story, not the canary mechanics.
### 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`.
Guardrail:
- the canary is always derived from the **next stable version**
- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0`
- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release
### 4. Smoke test the canary
Run the actual install path in Docker:
```bash
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
Useful isolated variants:
```bash
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
```
If you want to smoke onboarding from the current codebase rather than npm, run:
```bash
./scripts/clean-onboard-git.sh
./scripts/clean-onboard-ref.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 public-gh 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/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release
- [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI
## 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

View File

@@ -19,17 +19,23 @@
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
"build:npm": "./scripts/build-npm.sh",
"release": "./scripts/release.sh",
"release:preflight": "./scripts/release-preflight.sh",
"release:github": "./scripts/create-github-release.sh",
"release:rollback": "./scripts/rollback-latest.sh",
"changeset": "changeset",
"version-packages": "changeset version",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"docs:dev": "cd docs && npx mintlify dev",
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh"
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed"
},
"devDependencies": {
"@changesets/cli": "^2.30.0",
"cross-env": "^10.1.0",
"@playwright/test": "^1.58.2",
"esbuild": "^0.27.3",
"typescript": "^5.7.3",
"vitest": "^3.0.5"

47
releases/v0.3.0.md Normal file
View File

@@ -0,0 +1,47 @@
# v0.3.0
> Released: 2026-03-09
## Highlights
- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex.
- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation.
- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused.
- **PWA support** — The UI ships as an installable Progressive Web App with a service worker and enhanced manifest. The service worker uses a network-first strategy to prevent stale content.
- **Agent creation wizard** — A new choice modal and full-page configuration flow make it easier to add agents. The sidebar AGENTS header now has a quick-add button.
## Improvements
- **Mermaid diagrams in markdown** — Fenced `mermaid` blocks render as diagrams in issue comments and descriptions.
- **Live run output** — Run detail pages stream output over WebSocket in real time, with coalesced deltas and deduplicated feed items.
- **Copy comment as Markdown** — Each comment header has a one-click copy-as-markdown button.
- **Retry failed runs** — Failed and timed-out runs now show a Retry button on the run detail page.
- **Project status clickable** — The status chip in the project properties pane is now clickable for quick updates.
- **Scroll-to-bottom button** — Issue detail and run pages show a floating scroll-to-bottom button when you scroll up.
- **Database backup CLI** — `paperclipai db:backup` lets you snapshot the database on demand, with optional automatic scheduling.
- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration.
- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates.
- **Human-readable role labels** — The agent list and properties pane show friendly role names.
- **Assignee picker sorting** — Recent selections appear first, then alphabetical.
- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile.
- **Invite UX improvements** — Invite links auto-copy to clipboard, snippet-only flow in settings, 10-minute invite TTL, and clearer network-host guidance.
- **Permalink anchors on comments** — Each comment has a stable anchor link and a GET-by-ID API endpoint.
- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image.
- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants.
- **Playwright e2e tests** — New end-to-end test suite covering the onboarding wizard flow.
## Fixes
- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking.
- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes.
- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler.
- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers.
- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors.
- **500 error logging** — Error logs now include the actual error message and request context instead of generic pino-http output.
- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false.
- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode.
- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution.
- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures.
- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues.
- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode.
- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals.

View File

@@ -7,7 +7,7 @@ mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA"
echo "PC_TEST_ROOT: $PC_TEST_ROOT"
echo "PC_HOME: $PC_HOME"
cd $PC_TEST_ROOT
git clone github.com:paperclipai/paperclip.git repo
git clone https://github.com/paperclipai/paperclip.git repo
cd repo
pnpm install
env HOME="$PC_HOME" npm_config_cache="$PC_CACHE" npm_config_userconfig="$PC_HOME/.npmrc" \

86
scripts/clean-onboard-ref.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TARGET_REF="${1:-HEAD}"
usage() {
cat <<'EOF'
Usage:
./scripts/clean-onboard-ref.sh [git-ref]
Examples:
./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
./scripts/clean-onboard-ref.sh v0.2.7
Environment overrides:
KEEP_TEMP=1 Keep the temp directory and detached worktree for debugging
PC_TEST_ROOT=/tmp/custom Base temp directory to use
PC_DATA=/tmp/data Paperclip data dir to use
PAPERCLIP_HOST=127.0.0.1 Host passed to the onboarded server
PAPERCLIP_PORT=3232 Port passed to the onboarded server
Notes:
- Defaults to the current committed ref (HEAD), not uncommitted local edits.
- Creates an isolated temp HOME, npm cache, data dir, and detached git worktree.
EOF
}
if [ $# -gt 1 ]; then
usage
exit 1
fi
if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then
usage
exit 0
fi
TARGET_COMMIT="$(git -C "$REPO_ROOT" rev-parse --verify "${TARGET_REF}^{commit}")"
export KEEP_TEMP="${KEEP_TEMP:-0}"
export PC_TEST_ROOT="${PC_TEST_ROOT:-$(mktemp -d /tmp/paperclip-clean-ref.XXXXXX)}"
export PC_HOME="${PC_HOME:-$PC_TEST_ROOT/home}"
export PC_CACHE="${PC_CACHE:-$PC_TEST_ROOT/npm-cache}"
export PC_DATA="${PC_DATA:-$PC_TEST_ROOT/paperclip-data}"
export PC_REPO="${PC_REPO:-$PC_TEST_ROOT/repo}"
export PAPERCLIP_HOST="${PAPERCLIP_HOST:-127.0.0.1}"
export PAPERCLIP_PORT="${PAPERCLIP_PORT:-3100}"
export PAPERCLIP_OPEN_ON_LISTEN="${PAPERCLIP_OPEN_ON_LISTEN:-false}"
cleanup() {
if [ "$KEEP_TEMP" = "1" ]; then
return
fi
git -C "$REPO_ROOT" worktree remove --force "$PC_REPO" >/dev/null 2>&1 || true
rm -rf "$PC_TEST_ROOT"
}
trap cleanup EXIT
mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA"
echo "TARGET_REF: $TARGET_REF"
echo "TARGET_COMMIT: $TARGET_COMMIT"
echo "PC_TEST_ROOT: $PC_TEST_ROOT"
echo "PC_HOME: $PC_HOME"
echo "PC_DATA: $PC_DATA"
echo "PC_REPO: $PC_REPO"
echo "PAPERCLIP_HOST: $PAPERCLIP_HOST"
echo "PAPERCLIP_PORT: $PAPERCLIP_PORT"
git -C "$REPO_ROOT" worktree add --detach "$PC_REPO" "$TARGET_COMMIT"
cd "$PC_REPO"
pnpm install
env \
HOME="$PC_HOME" \
npm_config_cache="$PC_CACHE" \
npm_config_userconfig="$PC_HOME/.npmrc" \
HOST="$PAPERCLIP_HOST" \
PORT="$PAPERCLIP_PORT" \
PAPERCLIP_OPEN_ON_LISTEN="$PAPERCLIP_OPEN_ON_LISTEN" \
pnpm paperclipai onboard --yes --data-dir "$PC_DATA"

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
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 "$PUBLISH_REMOTE" "refs/tags/$tag" >/dev/null 2>&1; then
echo "Error: remote tag $tag was not found on $PUBLISH_REMOTE. 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

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
# prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist.
# This keeps @paperclipai/server publish artifacts self-contained for static UI serving.
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
UI_DIST="$REPO_ROOT/ui/dist"
SERVER_UI_DIST="$REPO_ROOT/server/ui-dist"
echo " -> Building @paperclipai/ui..."
pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build
if [ ! -f "$UI_DIST/index.html" ]; then
echo "Error: UI build output missing at $UI_DIST/index.html"
exit 1
fi
rm -rf "$SERVER_UI_DIST"
cp -r "$UI_DIST" "$SERVER_UI_DIST"
echo " -> Copied ui/dist to server/ui-dist"

207
scripts/release-preflight.sh Executable file
View File

@@ -0,0 +1,207 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export GIT_PAGER=cat
channel=""
bump_type=""
usage() {
cat <<'EOF'
Usage:
./scripts/release-preflight.sh <canary|stable> <patch|minor|major>
Examples:
./scripts/release-preflight.sh canary patch
./scripts/release-preflight.sh stable minor
What it does:
- verifies the git worktree is clean, including untracked files
- shows the last stable tag and the target version(s)
- shows commits since the last stable tag
- highlights migration/schema/breaking-change signals
- runs the verification gate:
pnpm -r typecheck
pnpm test:run
pnpm build
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
-h|--help)
usage
exit 0
;;
*)
if [ -z "$channel" ]; then
channel="$1"
elif [ -z "$bump_type" ]; then
bump_type="$1"
else
echo "Error: unexpected argument: $1" >&2
exit 1
fi
;;
esac
shift
done
if [ -z "$channel" ] || [ -z "$bump_type" ]; then
usage
exit 1
fi
if [[ ! "$channel" =~ ^(canary|stable)$ ]]; then
usage
exit 1
fi
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
usage
exit 1
fi
compute_bumped_version() {
node - "$1" "$2" <<'NODE'
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${current}`);
}
let [major, minor, patch] = match.slice(1).map(Number);
if (bump === 'patch') {
patch += 1;
} else if (bump === 'minor') {
minor += 1;
patch = 0;
} else if (bump === 'major') {
major += 1;
minor = 0;
patch = 0;
} else {
throw new Error(`unsupported bump type: ${bump}`);
}
process.stdout.write(`${major}.${minor}.${patch}`);
NODE
}
next_canary_version() {
local stable_version="$1"
local versions_json
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
node - "$stable_version" "$versions_json" <<'NODE'
const stable = process.argv[2];
const versionsArg = process.argv[3];
let versions = [];
try {
const parsed = JSON.parse(versionsArg);
versions = Array.isArray(parsed) ? parsed : [parsed];
} catch {
versions = [];
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
for (const version of versions) {
const match = version.match(pattern);
if (!match) continue;
max = Math.max(max, Number(match[1]));
}
process.stdout.write(`${stable}-canary.${max + 1}`);
NODE
}
LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)"
CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}"
if [ -z "$CURRENT_STABLE_VERSION" ]; then
CURRENT_STABLE_VERSION="0.0.0"
fi
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2
exit 1
fi
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
echo "Error: next stable version matches the current stable version." >&2
exit 1
fi
if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
echo "Error: canary target was derived from the current stable version, which is not allowed." >&2
exit 1
fi
echo ""
echo "==> Release preflight"
echo " Channel: $channel"
echo " Bump: $bump_type"
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
echo " Next canary version: $TARGET_CANARY_VERSION"
echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N"
fi
echo ""
echo "==> Working tree"
echo " ✓ Clean"
echo ""
echo "==> Commits since last stable tag"
if [ -n "$LAST_STABLE_TAG" ]; then
git -C "$REPO_ROOT" --no-pager log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true
else
git -C "$REPO_ROOT" --no-pager log --oneline --no-merges || true
fi
echo ""
echo "==> Migration / breaking change signals"
if [ -n "$LAST_STABLE_TAG" ]; then
echo "-- migrations --"
git -C "$REPO_ROOT" --no-pager diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true
echo "-- schema --"
git -C "$REPO_ROOT" --no-pager diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true
echo "-- breaking commit messages --"
git -C "$REPO_ROOT" --no-pager log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
else
echo "No stable tag exists yet. Review the full current tree manually."
fi
echo ""
echo "==> Verification gate"
cd "$REPO_ROOT"
pnpm -r typecheck
pnpm test:run
pnpm build
echo ""
echo "==> Release preflight summary"
echo " Channel: $channel"
echo " Bump: $bump_type"
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
echo " Next canary version: $TARGET_CANARY_VERSION"
echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N"
fi
echo ""
echo "Preflight passed for $channel release."

View File

@@ -1,422 +1,485 @@
#!/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"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
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"
tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)"
if [ -n "$tracked_changes" ]; then
printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do
[ -z "$path" ] && continue
git -C "$REPO_ROOT" checkout -q HEAD -- "$path" || true
done
fi
untracked_changes="$(git -C "$REPO_ROOT" ls-files --others --exclude-standard)"
if [ -n "$untracked_changes" ]; then
printf '%s\n' "$untracked_changes" | while IFS= read -r path; do
[ -z "$path" ] && continue
if [ -d "$REPO_ROOT/$path" ]; then
rm -rf "$REPO_ROOT/$path"
else
rm -f "$REPO_ROOT/$path"
fi
done
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
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
fail "next stable version matches the current stable version. Refusing to publish."
fi
if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
fi
PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)"
PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)"
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"
info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
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 --filter @paperclipai/ui build
rm -rf "$REPO_ROOT/server/ui-dist"
cp -r "$REPO_ROOT/ui/dist" "$REPO_ROOT/server/ui-dist"
# Bundle skills into packages that need them (adapters + server)
pnpm build
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
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"
npx changeset publish --tag canary
echo " ✓ Published all packages under @canary tag"
else
echo "==> Step 6/7: Publishing to npm..."
cd "$REPO_ROOT"
info "==> Step 6/7: Publishing canary to npm..."
npx changeset publish
echo " ✓ Published all packages"
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)..."
else
echo "==> Step 7/7: Restoring dev package.json, committing, and tagging..."
fi
cd "$REPO_ROOT"
# 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"
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 " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
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 "==> Step 6/7: Publishing stable release to npm..."
npx changeset publish
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
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"
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"
fi
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
info "==> Step 7/7: Finalizing stable release commit..."
restore_publish_artifacts
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
info ""
if [ "$dry_run" = true ]; then
if [ "$canary" = true ]; then
info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
else
info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
fi
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
info "Published stable v${TARGET_STABLE_VERSION}."
info "Next steps:"
info " git push ${PUBLISH_REMOTE} HEAD:master --follow-tags"
info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
fi

111
scripts/rollback-latest.sh Executable file
View 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"

View File

@@ -23,8 +23,11 @@
],
"scripts": {
"dev": "tsx src/index.ts",
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
"dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts",
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
"build": "tsc",
"prepack": "pnpm run prepare:ui-dist",
"postpack": "rm -rf ui-dist",
"clean": "rm -rf dist",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"

View File

@@ -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.

View File

@@ -1,402 +1,250 @@
---
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. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`.
6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing.
7. 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:
Run release preflight first:
```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
./scripts/release-preflight.sh canary {patch|minor|major}
# or
./scripts/release-preflight.sh stable {patch|minor|major}
```
Then detect minimum required bump:
Then use the last stable tag as the base:
```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
The GitHub Actions release workflow installs with `pnpm install --frozen-lockfile`. Treat that as a release invariant, not a nuisance: if manifests changed and the lockfile refresh PR has not landed yet, stop and wait for `master` to contain the committed lockfile before shipping.
Use `release.sh` with the `--canary` flag (see script changes below):
## Step 4 — Publish a Canary
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
Guard:
- if the current stable is `0.2.7`, the next patch canary is `0.2.8-canary.0`
- the tooling must never publish `0.2.7-canary.N` after `0.2.7` is already stable
After publish, verify:
```bash
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 public-gh 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

View File

@@ -0,0 +1,172 @@
import { test, expect } from "@playwright/test";
/**
* E2E: Onboarding wizard flow (skip_llm mode).
*
* Walks through the 4-step OnboardingWizard:
* Step 1 — Name your company
* Step 2 — Create your first agent (adapter selection + config)
* Step 3 — Give it something to do (task creation)
* Step 4 — Ready to launch (summary + open issue)
*
* By default this runs in skip_llm mode: we do NOT assert that an LLM
* heartbeat fires. Set PAPERCLIP_E2E_SKIP_LLM=false to enable LLM-dependent
* assertions (requires a valid ANTHROPIC_API_KEY).
*/
const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false";
const COMPANY_NAME = `E2E-Test-${Date.now()}`;
const AGENT_NAME = "CEO";
const TASK_TITLE = "E2E test task";
test.describe("Onboarding wizard", () => {
test("completes full wizard flow", async ({ page }) => {
// Navigate to root — should auto-open onboarding when no companies exist
await page.goto("/");
// If the wizard didn't auto-open (company already exists), click the button
const wizardHeading = page.locator("h3", { hasText: "Name your company" });
const newCompanyBtn = page.getByRole("button", { name: "New Company" });
// Wait for either the wizard or the start page
await expect(
wizardHeading.or(newCompanyBtn)
).toBeVisible({ timeout: 15_000 });
if (await newCompanyBtn.isVisible()) {
await newCompanyBtn.click();
}
// -----------------------------------------------------------
// Step 1: Name your company
// -----------------------------------------------------------
await expect(wizardHeading).toBeVisible({ timeout: 5_000 });
await expect(page.locator("text=Step 1 of 4")).toBeVisible();
const companyNameInput = page.locator('input[placeholder="Acme Corp"]');
await companyNameInput.fill(COMPANY_NAME);
// Click Next
const nextButton = page.getByRole("button", { name: "Next" });
await nextButton.click();
// -----------------------------------------------------------
// Step 2: Create your first agent
// -----------------------------------------------------------
await expect(
page.locator("h3", { hasText: "Create your first agent" })
).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=Step 2 of 4")).toBeVisible();
// Agent name should default to "CEO"
const agentNameInput = page.locator('input[placeholder="CEO"]');
await expect(agentNameInput).toHaveValue(AGENT_NAME);
// Claude Code adapter should be selected by default
await expect(
page.locator("button", { hasText: "Claude Code" }).locator("..")
).toBeVisible();
// Select the "Process" adapter to avoid needing a real CLI tool installed
await page.locator("button", { hasText: "Process" }).click();
// Fill in process adapter fields
const commandInput = page.locator('input[placeholder="e.g. node, python"]');
await commandInput.fill("echo");
const argsInput = page.locator(
'input[placeholder="e.g. script.js, --flag"]'
);
await argsInput.fill("hello");
// Click Next (process adapter skips environment test)
await page.getByRole("button", { name: "Next" }).click();
// -----------------------------------------------------------
// Step 3: Give it something to do
// -----------------------------------------------------------
await expect(
page.locator("h3", { hasText: "Give it something to do" })
).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=Step 3 of 4")).toBeVisible();
// Clear default title and set our test title
const taskTitleInput = page.locator(
'input[placeholder="e.g. Research competitor pricing"]'
);
await taskTitleInput.clear();
await taskTitleInput.fill(TASK_TITLE);
// Click Next
await page.getByRole("button", { name: "Next" }).click();
// -----------------------------------------------------------
// Step 4: Ready to launch
// -----------------------------------------------------------
await expect(
page.locator("h3", { hasText: "Ready to launch" })
).toBeVisible({ timeout: 10_000 });
await expect(page.locator("text=Step 4 of 4")).toBeVisible();
// Verify summary displays our created entities
await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible();
await expect(page.locator("text=" + AGENT_NAME)).toBeVisible();
await expect(page.locator("text=" + TASK_TITLE)).toBeVisible();
// Click "Open Issue"
await page.getByRole("button", { name: "Open Issue" }).click();
// Should navigate to the issue page
await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
// -----------------------------------------------------------
// Verify via API that entities were created
// -----------------------------------------------------------
const baseUrl = page.url().split("/").slice(0, 3).join("/");
// List companies and find ours
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json();
const company = companies.find(
(c: { name: string }) => c.name === COMPANY_NAME
);
expect(company).toBeTruthy();
// List agents for our company
const agentsRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/agents`
);
expect(agentsRes.ok()).toBe(true);
const agents = await agentsRes.json();
const ceoAgent = agents.find(
(a: { name: string }) => a.name === AGENT_NAME
);
expect(ceoAgent).toBeTruthy();
expect(ceoAgent.role).toBe("ceo");
expect(ceoAgent.adapterType).toBe("process");
// List issues for our company
const issuesRes = await page.request.get(
`${baseUrl}/api/companies/${company.id}/issues`
);
expect(issuesRes.ok()).toBe(true);
const issues = await issuesRes.json();
const task = issues.find(
(i: { title: string }) => i.title === TASK_TITLE
);
expect(task).toBeTruthy();
expect(task.assigneeAgentId).toBe(ceoAgent.id);
if (!SKIP_LLM) {
// LLM-dependent: wait for the heartbeat to transition the issue
await expect(async () => {
const res = await page.request.get(
`${baseUrl}/api/issues/${task.id}`
);
const issue = await res.json();
expect(["in_progress", "done"]).toContain(issue.status);
}).toPass({ timeout: 120_000, intervals: [5_000] });
}
});
});

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "@playwright/test";
const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3100);
const BASE_URL = `http://127.0.0.1:${PORT}`;
export default defineConfig({
testDir: ".",
testMatch: "**/*.spec.ts",
timeout: 60_000,
retries: 0,
use: {
baseURL: BASE_URL,
headless: true,
screenshot: "only-on-failure",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { browserName: "chromium" },
},
],
// The webServer directive starts `paperclipai run` before tests.
// Expects `pnpm paperclipai` to be runnable from repo root.
webServer: {
command: `pnpm paperclipai run --yes`,
url: `${BASE_URL}/api/health`,
reuseExistingServer: !!process.env.CI,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
outputDir: "./test-results",
reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]],
});

View File

@@ -7,6 +7,7 @@ import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { formatDate, cn } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { StatusIcon } from "./StatusIcon";
import { PriorityIcon } from "./PriorityIcon";
import { EmptyState } from "./EmptyState";
@@ -17,7 +18,7 @@ import { Input } from "@/components/ui/input";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search, ArrowDown } from "lucide-react";
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
import { KanbanBoard } from "./KanbanBoard";
import type { Issue } from "@paperclipai/shared";
@@ -233,24 +234,6 @@ export function IssuesList({
const activeFilterCount = countActiveFilters(viewState);
const [showScrollBottom, setShowScrollBottom] = useState(false);
useEffect(() => {
const el = document.getElementById("main-content");
if (!el) return;
const check = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setShowScrollBottom(distanceFromBottom > 300);
};
check();
el.addEventListener("scroll", check, { passive: true });
return () => el.removeEventListener("scroll", check);
}, [filtered.length]);
const scrollToBottom = useCallback(() => {
const el = document.getElementById("main-content");
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}, []);
const groupedContent = useMemo(() => {
if (viewState.groupBy === "none") {
return [{ key: "__all", label: null as string | null, items: filtered }];
@@ -608,22 +591,57 @@ export function IssuesList({
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex items-center gap-2 py-2 pl-1 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit"
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1"
>
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
<div className="w-3.5 shrink-0 hidden sm:block" />
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
{/* Status icon - left column on mobile, inline on desktop */}
<span className="shrink-0 pt-px sm:hidden" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</div>
<span className="text-sm text-muted-foreground font-mono shrink-0">
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
{/* Title line */}
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
{/* Metadata line */}
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
<span className="w-3.5 shrink-0 hidden sm:block" />
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden shrink-0 sm:inline-flex" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
<span className="text-xs text-muted-foreground font-mono shrink-0">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="truncate flex-1 min-w-0">{issue.title}</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
</span>
)}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground sm:hidden">
{timeAgo(issue.updatedAt)}
</span>
</span>
</span>
{/* Desktop-only trailing content */}
<span className="hidden sm:flex sm:order-3 items-center gap-2 sm:gap-3 shrink-0 ml-auto">
{(issue.labels ?? []).length > 0 && (
<div className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
<span className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
@@ -640,19 +658,8 @@ export function IssuesList({
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
)}
</div>
)}
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
</span>
)}
<div className="hidden sm:block">
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
@@ -731,26 +738,16 @@ export function IssuesList({
</div>
</PopoverContent>
</Popover>
</div>
<span className="text-xs text-muted-foreground hidden sm:inline">
<span className="text-xs text-muted-foreground">
{formatDate(issue.createdAt)}
</span>
</div>
</span>
</Link>
))}
</CollapsibleContent>
</Collapsible>
))
)}
{showScrollBottom && (
<button
onClick={scrollToBottom}
className="fixed bottom-6 right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
)}
</div>
);
}

View File

@@ -831,7 +831,7 @@ export function NewIssueDialog() {
placeholder="Add description..."
bordered={false}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")}
contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;

View File

@@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from "react";
import { ArrowDown } from "lucide-react";
/**
* Floating scroll-to-bottom button that appears when the user is far from the
* bottom of the `#main-content` scroll container. Hides when within 300px of
* the bottom. Positioned to avoid the mobile bottom nav.
*/
export function ScrollToBottom() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = document.getElementById("main-content");
if (!el) return;
const check = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setVisible(distance > 300);
};
check();
el.addEventListener("scroll", check, { passive: true });
return () => el.removeEventListener("scroll", check);
}, []);
const scroll = useCallback(() => {
const el = document.getElementById("main-content");
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}, []);
if (!visible) return null;
return (
<button
onClick={scroll}
className="fixed bottom-[calc(1.5rem+5rem+env(safe-area-inset-bottom))] right-6 z-40 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-background shadow-md hover:bg-accent transition-colors md:bottom-6"
aria-label="Scroll to bottom"
>
<ArrowDown className="h-4 w-4" />
</button>
);
}

View File

@@ -24,6 +24,7 @@ import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
@@ -1747,6 +1748,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen
{/* Log viewer */}
<LogViewer run={run} adapterType={adapterType} />
<ScrollToBottom />
</div>
);
}

View File

@@ -313,27 +313,37 @@ export function Dashboard() {
<Link
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
className="px-4 py-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block"
>
<div className="flex gap-3">
<div className="flex items-start gap-2 min-w-0 flex-1">
<div className="flex items-center gap-2 shrink-0 mt-0.5">
<PriorityIcon priority={issue.priority} />
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</div>
<p className="min-w-0 flex-1 truncate">
<span>{issue.title}</span>
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{issue.assigneeAgentId && (() => {
const name = agentName(issue.assigneeAgentId);
return name
? <span className="hidden sm:inline"><Identity name={name} size="sm" className="ml-2 inline-flex" /></span>
? <span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
: null;
})()}
</p>
</div>
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
{timeAgo(issue.updatedAt)}
</span>
</span>
</span>
</div>
</Link>
))}

View File

@@ -841,38 +841,44 @@ export function Inbox() {
{staleIssues.map((issue) => (
<div
key={issue.id}
className="group/stale relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
className="group/stale relative flex items-start gap-2 overflow-hidden px-3 py-3 transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
{/* Status icon - left column on mobile; Clock icon on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground hidden sm:block sm:mt-0" />
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex flex-1 cursor-pointer items-center gap-3 no-underline text-inherit"
className="flex min-w-0 flex-1 cursor-pointer flex-col gap-1 no-underline text-inherit sm:flex-row sm:items-center sm:gap-3"
>
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="text-xs font-mono text-muted-foreground">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="shrink-0 text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="flex-1 truncate text-sm">{issue.title}</span>
{issue.assigneeAgentId &&
(() => {
const name = agentName(issue.assigneeAgentId);
return name ? (
<Identity name={name} size="sm" />
) : (
<span className="font-mono text-xs text-muted-foreground">
{issue.assigneeAgentId.slice(0, 8)}
</span>
);
<span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
) : null;
})()}
<span className="shrink-0 text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="shrink-0 text-xs text-muted-foreground sm:order-last">
updated {timeAgo(issue.updatedAt)}
</span>
</span>
</Link>
<button
type="button"
onClick={() => dismiss(`stale:${issue.id}`)}
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100"
className="mt-0.5 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/stale:opacity-100 sm:mt-0"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
@@ -896,47 +902,94 @@ export function Inbox() {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
<div
<Link
key={issue.id}
className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
<span className="flex w-4 shrink-0 justify-center">
{(isUnread || isFading) && (
<button
type="button"
{/* Status icon - left column on mobile, inline on desktop */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{(isUnread || isFading) ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
className="group/dot flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="hidden sm:inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={`h-2.5 w-2.5 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
className={`h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</button>
)}
</span>
<Link
to={`/issues/${issue.identifier ?? issue.id}`}
className="flex flex-1 min-w-0 cursor-pointer items-center gap-3 no-underline text-inherit"
>
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
) : (
<span className="hidden sm:inline-flex h-4 w-4 shrink-0" />
)}
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="flex-1 truncate text-sm">{issue.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground sm:hidden">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:order-last">
{issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</span>
</span>
{/* Unread dot - right side, vertically centered (mobile only; desktop keeps inline) */}
{(isUnread || isFading) && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
markReadMutation.mutate(issue.id);
}
}}
className="shrink-0 self-center cursor-pointer sm:hidden"
aria-label="Mark as read"
>
<span
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
</span>
)}
</Link>
</div>
);
})}
</div>

View File

@@ -18,6 +18,7 @@ import { CommentThread } from "../components/CommentThread";
import { IssueProperties } from "../components/IssueProperties";
import { LiveRunWidget } from "../components/LiveRunWidget";
import type { MentionOption } from "../components/MarkdownEditor";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusBadge } from "../components/StatusBadge";
@@ -926,6 +927,7 @@ export function IssueDetail() {
</ScrollArea>
</SheetContent>
</Sheet>
<ScrollToBottom />
</div>
);
}