Merge branch 'master' into fix/windows-command-compat
This commit is contained in:
44
.github/workflows/e2e.yml
vendored
Normal file
44
.github/workflows/e2e.yml
vendored
Normal 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
|
||||
1
.github/workflows/pr-policy.yml
vendored
1
.github/workflows/pr-policy.yml
vendored
@@ -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
|
||||
|
||||
43
.github/workflows/refresh-lockfile.yml
vendored
43
.github/workflows/refresh-lockfile.yml
vendored
@@ -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
132
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,132 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel
|
||||
required: true
|
||||
type: choice
|
||||
default: canary
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
bump:
|
||||
description: Semantic version bump
|
||||
required: true
|
||||
type: choice
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
dry_run:
|
||||
description: Preview the release without publishing
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: github.ref == 'refs/heads/master'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --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
4
.gitignore
vendored
@@ -37,3 +37,7 @@ tmp/
|
||||
.vscode/
|
||||
.claude/settings.local.json
|
||||
.paperclip-local/
|
||||
|
||||
# Playwright
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
@@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: {
|
||||
provider: "local_disk",
|
||||
|
||||
@@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
|
||||
@@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): {
|
||||
},
|
||||
auth: {
|
||||
baseUrlMode: authBaseUrlMode,
|
||||
disableSignUp: false,
|
||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||
},
|
||||
storage: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
526
doc/RELEASING.md
Normal 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
|
||||
@@ -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
47
releases/v0.3.0.md
Normal 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.
|
||||
@@ -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
86
scripts/clean-onboard-ref.sh
Executable 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"
|
||||
87
scripts/create-github-release.sh
Executable file
87
scripts/create-github-release.sh
Executable 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
|
||||
21
scripts/prepare-server-ui-dist.sh
Executable file
21
scripts/prepare-server-ui-dist.sh
Executable 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
207
scripts/release-preflight.sh
Executable 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."
|
||||
@@ -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
111
scripts/rollback-latest.sh
Executable file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
dry_run=false
|
||||
version=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/rollback-latest.sh <stable-version> [--dry-run]
|
||||
|
||||
Examples:
|
||||
./scripts/rollback-latest.sh 1.2.2
|
||||
./scripts/rollback-latest.sh 1.2.2 --dry-run
|
||||
|
||||
Notes:
|
||||
- This repoints the npm dist-tag "latest" for every public package.
|
||||
- It does not unpublish anything.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=true ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
if [ -n "$version" ]; then
|
||||
echo "Error: only one version may be provided." >&2
|
||||
exit 1
|
||||
fi
|
||||
version="$1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: version must be a stable semver like 1.2.2." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$dry_run" = false ] && ! npm whoami >/dev/null 2>&1; then
|
||||
echo "Error: npm publish rights are required. Run 'npm login' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
list_public_package_names() {
|
||||
node - "$REPO_ROOT" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = process.argv[2];
|
||||
const roots = ['packages', 'server', 'ui', 'cli'];
|
||||
const seen = new Set();
|
||||
|
||||
function walk(relDir) {
|
||||
const absDir = path.join(root, relDir);
|
||||
const pkgPath = path.join(absDir, 'package.json');
|
||||
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (!pkg.private && !seen.has(pkg.name)) {
|
||||
seen.add(pkg.name);
|
||||
process.stdout.write(`${pkg.name}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
||||
walk(path.join(relDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of roots) {
|
||||
walk(rel);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
package_names="$(list_public_package_names)"
|
||||
|
||||
if [ -z "$package_names" ]; then
|
||||
echo "Error: no public packages were found in the workspace." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS= read -r package_name; do
|
||||
[ -z "$package_name" ] && continue
|
||||
if [ "$dry_run" = true ]; then
|
||||
echo "[dry-run] npm dist-tag add ${package_name}@${version} latest"
|
||||
else
|
||||
npm dist-tag add "${package_name}@${version}" latest
|
||||
echo "Updated latest -> ${package_name}@${version}"
|
||||
fi
|
||||
done <<< "$package_names"
|
||||
@@ -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"
|
||||
|
||||
@@ -1,363 +1,140 @@
|
||||
---
|
||||
name: release-changelog
|
||||
description: >
|
||||
Generate user-facing release changelogs for Paperclip. Reads git history,
|
||||
merged PRs, and changeset files since the last release tag. Detects breaking
|
||||
changes, categorizes changes, and outputs structured markdown to
|
||||
releases/v{version}.md. Use when preparing a release or when asked to
|
||||
generate a changelog.
|
||||
Generate the stable Paperclip release changelog at releases/v{version}.md by
|
||||
reading commits, changesets, and merged PR context since the last stable tag.
|
||||
---
|
||||
|
||||
# Release Changelog Skill
|
||||
|
||||
Generate a user-facing changelog for a new Paperclip release. This skill reads
|
||||
the commit history, changeset files, and merged PRs since the last release tag,
|
||||
detects breaking changes, categorizes everything, and writes a structured
|
||||
release notes file.
|
||||
Generate the user-facing changelog for the **stable** Paperclip release.
|
||||
|
||||
**Output:** `releases/v{version}.md` in the repo root.
|
||||
**Review required:** Always present the draft for human sign-off before
|
||||
finalizing. Never auto-publish.
|
||||
Output:
|
||||
|
||||
---
|
||||
- `releases/v{version}.md`
|
||||
|
||||
Important rule:
|
||||
|
||||
- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md`
|
||||
|
||||
## Step 0 — Idempotency Check
|
||||
|
||||
Before generating anything, check if a changelog already exists for this version:
|
||||
Before generating anything, check whether the file already exists:
|
||||
|
||||
```bash
|
||||
ls releases/v{version}.md 2>/dev/null
|
||||
```
|
||||
|
||||
**If the file already exists:**
|
||||
If it exists:
|
||||
|
||||
1. Read the existing changelog and present it to the reviewer.
|
||||
2. Ask: "A changelog for v{version} already exists. Do you want to (a) keep it
|
||||
as-is, (b) regenerate from scratch, or (c) update specific sections?"
|
||||
3. If the reviewer says keep it → **stop here**. Do not overwrite. This skill is
|
||||
done.
|
||||
4. If the reviewer says regenerate → back up the existing file to
|
||||
`releases/v{version}.md.prev`, then proceed from Step 1.
|
||||
5. If the reviewer says update → read the existing file, proceed through Steps
|
||||
1-4 to gather fresh data, then merge changes into the existing file rather
|
||||
than replacing it wholesale. Preserve any manual edits the reviewer previously
|
||||
made.
|
||||
1. read it first
|
||||
2. present it to the reviewer
|
||||
3. ask whether to keep it, regenerate it, or update specific sections
|
||||
4. never overwrite it silently
|
||||
|
||||
**If the file does not exist:** Proceed normally from Step 1.
|
||||
## Step 1 — Determine the Stable Range
|
||||
|
||||
**Critical rule:** This skill NEVER triggers a version bump. It only reads git
|
||||
history and writes a markdown file. The `release.sh` script is the only thing
|
||||
that bumps versions, and it is called separately by the `release` coordination
|
||||
skill. Running this skill multiple times is always safe — worst case it
|
||||
overwrites a draft changelog (with reviewer permission).
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Determine the Release Range
|
||||
|
||||
Find the last release tag and the planned version:
|
||||
Find the last stable tag:
|
||||
|
||||
```bash
|
||||
# Last release tag (most recent semver tag)
|
||||
git tag --sort=-version:refname | head -1
|
||||
# e.g. v0.2.7
|
||||
|
||||
# All commits since that tag
|
||||
git log v0.2.7..HEAD --oneline --no-merges
|
||||
git tag --list 'v*' --sort=-version:refname | head -1
|
||||
git log v{last}..HEAD --oneline --no-merges
|
||||
```
|
||||
|
||||
If no tag exists yet, use the initial commit as the base.
|
||||
The planned stable version comes from one of:
|
||||
|
||||
The new version number comes from one of:
|
||||
- An explicit argument (e.g. "generate changelog for v0.3.0")
|
||||
- The bump type (patch/minor/major) applied to the last tag
|
||||
- The version already set in `cli/package.json` if `scripts/release.sh` has been run
|
||||
- an explicit maintainer request
|
||||
- the chosen bump type applied to the last stable tag
|
||||
- the release plan already agreed in `doc/RELEASING.md`
|
||||
|
||||
---
|
||||
Do not derive the changelog version from a canary tag or prerelease suffix.
|
||||
|
||||
## Step 2 — Gather Raw Change Data
|
||||
## Step 2 — Gather the Raw Inputs
|
||||
|
||||
Collect changes from three sources, in priority order:
|
||||
Collect release data from:
|
||||
|
||||
### 2a. Git Commits
|
||||
1. git commits since the last stable tag
|
||||
2. `.changeset/*.md` files
|
||||
3. merged PRs via `gh` when available
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
git log v{last}..HEAD --oneline --no-merges
|
||||
git log v{last}..HEAD --format="%H %s" --no-merges # full SHAs for file diffs
|
||||
```
|
||||
|
||||
### 2b. Changeset Files
|
||||
|
||||
Look for unconsumed changesets in `.changeset/`:
|
||||
|
||||
```bash
|
||||
git log v{last}..HEAD --format="%H %s" --no-merges
|
||||
ls .changeset/*.md | grep -v README.md
|
||||
```
|
||||
|
||||
Each changeset file has YAML frontmatter with package names and bump types
|
||||
(`patch`, `minor`, `major`), followed by a description. Parse these — the bump
|
||||
type is a strong categorization signal, and the description may contain
|
||||
user-facing summaries.
|
||||
|
||||
### 2c. Merged PRs (when available)
|
||||
|
||||
If GitHub access is available via `gh`:
|
||||
|
||||
```bash
|
||||
gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels
|
||||
```
|
||||
|
||||
PR titles and bodies are often the best source of user-facing descriptions.
|
||||
Prefer PR descriptions over raw commit messages when both are available.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Detect Breaking Changes
|
||||
|
||||
Scan for breaking changes using these signals. **Any match flags the release as
|
||||
containing breaking changes**, which affects version bump requirements and
|
||||
changelog structure.
|
||||
Look for:
|
||||
|
||||
### 3a. Migration Files
|
||||
- destructive migrations
|
||||
- removed or changed API fields/endpoints
|
||||
- renamed or removed config keys
|
||||
- `major` changesets
|
||||
- `BREAKING:` or `BREAKING CHANGE:` commit signals
|
||||
|
||||
Check for new migration files since the last tag:
|
||||
Key commands:
|
||||
|
||||
```bash
|
||||
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
|
||||
```
|
||||
|
||||
- **New migration files exist** = DB migration required in upgrade.
|
||||
- Inspect migration content: look for `DROP`, `ALTER ... DROP`, `RENAME` to
|
||||
distinguish destructive vs. additive migrations.
|
||||
- Additive-only migrations (new tables, new nullable columns, new indexes) are
|
||||
safe but should still be mentioned.
|
||||
- Destructive migrations (column drops, type changes, table drops) = breaking.
|
||||
|
||||
### 3b. Schema Changes
|
||||
|
||||
```bash
|
||||
git diff v{last}..HEAD -- packages/db/src/schema/
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Removed or renamed columns/tables
|
||||
- Changed column types
|
||||
- Removed default values or nullable constraints
|
||||
- These indicate breaking DB changes even if no explicit migration file exists
|
||||
|
||||
### 3c. API Route Changes
|
||||
|
||||
```bash
|
||||
git diff v{last}..HEAD -- server/src/routes/ server/src/api/
|
||||
git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Removed endpoints
|
||||
- Changed request/response shapes (removed fields, type changes)
|
||||
- Changed authentication requirements
|
||||
If the requested bump is lower than the minimum required bump, flag that before the release proceeds.
|
||||
|
||||
### 3d. Config Changes
|
||||
## Step 4 — Categorize for Users
|
||||
|
||||
```bash
|
||||
git diff v{last}..HEAD -- cli/src/config/ packages/*/src/*config*
|
||||
```
|
||||
Use these stable changelog sections:
|
||||
|
||||
Look for renamed, removed, or restructured configuration keys.
|
||||
- `Breaking Changes`
|
||||
- `Highlights`
|
||||
- `Improvements`
|
||||
- `Fixes`
|
||||
- `Upgrade Guide` when needed
|
||||
|
||||
### 3e. Changeset Severity
|
||||
Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users.
|
||||
|
||||
Any `.changeset/*.md` file with a `major` bump = explicitly flagged breaking.
|
||||
Guidelines:
|
||||
|
||||
### 3f. Commit Conventions
|
||||
- group related commits into one user-facing entry
|
||||
- write from the user perspective
|
||||
- keep highlights short and concrete
|
||||
- spell out upgrade actions for breaking changes
|
||||
|
||||
Scan commit messages for:
|
||||
- `BREAKING:` or `BREAKING CHANGE:` prefix
|
||||
- `!` after the type in conventional commits (e.g. `feat!:`, `fix!:`)
|
||||
## Step 5 — Write the File
|
||||
|
||||
### Version Bump Rules
|
||||
|
||||
| Condition | Minimum Bump |
|
||||
|---|---|
|
||||
| Destructive migration (DROP, RENAME) | `major` |
|
||||
| Removed API endpoints or fields | `major` |
|
||||
| Any `major` changeset or `BREAKING:` commit | `major` |
|
||||
| New (additive) migration | `minor` |
|
||||
| New features (`feat:` commits, `minor` changesets) | `minor` |
|
||||
| Bug fixes only | `patch` |
|
||||
|
||||
If the planned bump is lower than the minimum required, **warn the reviewer**
|
||||
and recommend the correct bump level.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Categorize Changes
|
||||
|
||||
Assign every meaningful change to one of these categories:
|
||||
|
||||
| Category | What Goes Here | Shows in User Notes? |
|
||||
|---|---|---|
|
||||
| **Breaking Changes** | Anything requiring user action to upgrade | Yes (top, with warning) |
|
||||
| **Highlights** | New user-visible features, major behavioral changes | Yes (with 1-2 sentence descriptions) |
|
||||
| **Improvements** | Enhancements to existing features | Yes (bullet list) |
|
||||
| **Fixes** | Bug fixes | Yes (bullet list) |
|
||||
| **Internal** | Refactoring, deps, CI, tests, docs | No (dev changelog only) |
|
||||
|
||||
### Categorization Heuristics
|
||||
|
||||
Use these signals to auto-categorize. When signals conflict, prefer the
|
||||
higher-visibility category and flag for human review.
|
||||
|
||||
| Signal | Category |
|
||||
|---|---|
|
||||
| Commit touches migration files, schema changes | Breaking Change (if destructive) |
|
||||
| Changeset marked `major` | Breaking Change |
|
||||
| Commit message has `BREAKING:` or `!:` | Breaking Change |
|
||||
| New UI components, new routes, new API endpoints | Highlight |
|
||||
| Commit message starts with `feat:` or `add:` | Highlight or Improvement |
|
||||
| Changeset marked `minor` | Highlight |
|
||||
| Commit message starts with `fix:` or `bug:` | Fix |
|
||||
| Changeset marked `patch` | Fix or Improvement |
|
||||
| Commit message starts with `chore:`, `refactor:`, `ci:`, `test:`, `docs:` | Internal |
|
||||
| PR has detailed body with user-facing description | Use PR body as the description |
|
||||
|
||||
### Writing Good Descriptions
|
||||
|
||||
- **Highlights** get 1-2 sentence descriptions explaining the user benefit.
|
||||
Write from the user's perspective ("You can now..." not "Added a component that...").
|
||||
- **Improvements and Fixes** are concise bullet points.
|
||||
- **Breaking Changes** get detailed descriptions including what changed,
|
||||
why, and what the user needs to do.
|
||||
- Group related commits into a single changelog entry. Five commits implementing
|
||||
one feature = one Highlight entry, not five bullets.
|
||||
- Omit purely internal changes from user-facing notes entirely.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Write the Changelog
|
||||
|
||||
Output the changelog to `releases/v{version}.md` using this template:
|
||||
Template:
|
||||
|
||||
```markdown
|
||||
# v{version}
|
||||
|
||||
> Released: {YYYY-MM-DD}
|
||||
|
||||
{If breaking changes detected, include this section:}
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
> **Action required before upgrading.** Read the Upgrade Guide below.
|
||||
|
||||
- **{Breaking change title}** — {What changed and why. What the user needs to do.}
|
||||
|
||||
## Highlights
|
||||
|
||||
- **{Feature name}** — {1-2 sentence description of what it does and why it matters.}
|
||||
|
||||
## Improvements
|
||||
|
||||
- {Concise description of improvement}
|
||||
|
||||
## Fixes
|
||||
|
||||
- {Concise description of fix}
|
||||
|
||||
---
|
||||
|
||||
{If breaking changes detected, include this section:}
|
||||
|
||||
## Upgrade Guide
|
||||
|
||||
### Before You Update
|
||||
|
||||
1. **Back up your database.**
|
||||
- SQLite: `cp paperclip.db paperclip.db.backup`
|
||||
- Postgres: `pg_dump -Fc paperclip > paperclip-pre-{version}.dump`
|
||||
2. **Note your current version:** `paperclip --version`
|
||||
|
||||
### After Updating
|
||||
|
||||
{Specific steps: run migrations, update configs, etc.}
|
||||
|
||||
### Rolling Back
|
||||
|
||||
If something goes wrong:
|
||||
1. Restore your database backup
|
||||
2. `npm install @paperclipai/server@{previous-version}`
|
||||
```
|
||||
|
||||
### Template Rules
|
||||
Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist.
|
||||
|
||||
- Omit any empty section entirely (don't show "## Fixes" with no bullets).
|
||||
- The Breaking Changes section always comes first when present.
|
||||
- The Upgrade Guide always comes last when present.
|
||||
- Use `**bold**` for feature/change names, regular text for descriptions.
|
||||
- Keep the entire changelog scannable — a busy user should get the gist from
|
||||
headings and bold text alone.
|
||||
## Step 6 — Review Before Release
|
||||
|
||||
---
|
||||
Before handing it off:
|
||||
|
||||
## Step 6 — Present for Review
|
||||
1. confirm the heading is the stable version only
|
||||
2. confirm there is no `-canary` language in the title or filename
|
||||
3. confirm any breaking changes have an upgrade path
|
||||
4. present the draft for human sign-off
|
||||
|
||||
After generating the draft:
|
||||
|
||||
1. **Show the full changelog** to the reviewer (CTO or whoever triggered the release).
|
||||
2. **Flag ambiguous items** — commits you weren't sure how to categorize, or
|
||||
items that might be breaking but aren't clearly signaled.
|
||||
3. **Flag version bump mismatches** — if the planned bump is lower than what
|
||||
the changes warrant.
|
||||
4. **Wait for approval** before considering the changelog final.
|
||||
|
||||
If the reviewer requests edits, update `releases/v{version}.md` accordingly.
|
||||
|
||||
Do not proceed to publishing, website updates, or social announcements. Those
|
||||
are handled by the `release` coordination skill (separate from this one).
|
||||
|
||||
---
|
||||
|
||||
## Directory Convention
|
||||
|
||||
Release changelogs live in `releases/` at the repo root:
|
||||
|
||||
```
|
||||
releases/
|
||||
v0.2.7.md
|
||||
v0.3.0.md
|
||||
...
|
||||
```
|
||||
|
||||
Each file is named `v{version}.md` matching the git tag. This directory is
|
||||
committed to the repo and serves as the source of truth for release history.
|
||||
|
||||
The `releases/` directory should be created with a `.gitkeep` if it doesn't
|
||||
exist yet.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Full workflow summary:
|
||||
|
||||
# 1. Find last tag
|
||||
LAST_TAG=$(git tag --sort=-version:refname | head -1)
|
||||
|
||||
# 2. Commits since last tag
|
||||
git log $LAST_TAG..HEAD --oneline --no-merges
|
||||
|
||||
# 3. Files changed (for breaking change detection)
|
||||
git diff --name-only $LAST_TAG..HEAD
|
||||
|
||||
# 4. Migration changes specifically
|
||||
git diff --name-only $LAST_TAG..HEAD -- packages/db/src/migrations/
|
||||
|
||||
# 5. Schema changes
|
||||
git diff $LAST_TAG..HEAD -- packages/db/src/schema/
|
||||
|
||||
# 6. Unconsumed changesets
|
||||
ls .changeset/*.md | grep -v README.md
|
||||
|
||||
# 7. Merged PRs (if gh available)
|
||||
gh pr list --state merged --search "merged:>=$(git log -1 --format=%aI $LAST_TAG)" \
|
||||
--json number,title,body,labels
|
||||
```
|
||||
This skill never publishes anything. It only prepares the stable changelog artifact.
|
||||
|
||||
@@ -1,402 +1,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
|
||||
|
||||
172
tests/e2e/onboarding.spec.ts
Normal file
172
tests/e2e/onboarding.spec.ts
Normal 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] });
|
||||
}
|
||||
});
|
||||
});
|
||||
35
tests/e2e/playwright.config.ts
Normal file
35
tests/e2e/playwright.config.ts
Normal 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" }]],
|
||||
});
|
||||
@@ -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">·</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
40
ui/src/components/ScrollToBottom.tsx
Normal file
40
ui/src/components/ScrollToBottom.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">·</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
|
||||
{timeAgo(issue.updatedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -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">·</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">
|
||||
·
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user