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
|
node-version: 20
|
||||||
|
|
||||||
- name: Block manual lockfile edits
|
- name: Block manual lockfile edits
|
||||||
|
if: github.head_ref != 'chore/refresh-lockfile'
|
||||||
run: |
|
run: |
|
||||||
changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")"
|
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
|
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
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
refresh_and_verify:
|
refresh:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -40,6 +41,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
changed="$(git status --porcelain)"
|
changed="$(git status --porcelain)"
|
||||||
if [ -z "$changed" ]; then
|
if [ -z "$changed" ]; then
|
||||||
|
echo "Lockfile is already up to date."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
||||||
@@ -48,29 +50,32 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Commit refreshed lockfile
|
- name: Create or update pull request
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
if git diff --quiet -- pnpm-lock.yaml; then
|
if git diff --quiet -- pnpm-lock.yaml; then
|
||||||
|
echo "Lockfile unchanged, nothing to do."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
BRANCH="chore/refresh-lockfile"
|
||||||
git config user.name "lockfile-bot"
|
git config user.name "lockfile-bot"
|
||||||
git config user.email "lockfile-bot@users.noreply.github.com"
|
git config user.email "lockfile-bot@users.noreply.github.com"
|
||||||
|
|
||||||
|
git checkout -B "$BRANCH"
|
||||||
git add pnpm-lock.yaml
|
git add pnpm-lock.yaml
|
||||||
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
git commit -m "chore(lockfile): refresh pnpm-lock.yaml"
|
||||||
git push || {
|
git push --force origin "$BRANCH"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Install dependencies
|
# Create PR if one doesn't already exist
|
||||||
run: pnpm install --frozen-lockfile
|
existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')
|
||||||
|
if [ -z "$existing" ]; then
|
||||||
- name: Typecheck
|
gh pr create \
|
||||||
run: pnpm -r typecheck
|
--head "$BRANCH" \
|
||||||
|
--title "chore(lockfile): refresh pnpm-lock.yaml" \
|
||||||
- name: Run tests
|
--body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml."
|
||||||
run: pnpm test:run
|
echo "Created new PR."
|
||||||
|
else
|
||||||
- name: Build
|
echo "PR #$existing already exists, branch updated via force push."
|
||||||
run: pnpm build
|
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"
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -36,4 +36,8 @@ tmp/
|
|||||||
*.tmp
|
*.tmp
|
||||||
.vscode/
|
.vscode/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.paperclip-local/
|
.paperclip-local/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
tests/e2e/test-results/
|
||||||
|
tests/e2e/playwright-report/
|
||||||
@@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) {
|
|||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
baseUrlMode: "auto",
|
baseUrlMode: "auto",
|
||||||
|
disableSignUp: false,
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
provider: "local_disk",
|
provider: "local_disk",
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig {
|
|||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
baseUrlMode: "auto",
|
baseUrlMode: "auto",
|
||||||
|
disableSignUp: false,
|
||||||
},
|
},
|
||||||
storage: defaultStorageConfig(),
|
storage: defaultStorageConfig(),
|
||||||
secrets: defaultSecretsConfig(),
|
secrets: defaultSecretsConfig(),
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): {
|
|||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
baseUrlMode: authBaseUrlMode,
|
baseUrlMode: authBaseUrlMode,
|
||||||
|
disableSignUp: false,
|
||||||
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}),
|
||||||
},
|
},
|
||||||
storage: {
|
storage: {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export async function promptServer(opts?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const port = Number(portStr) || 3100;
|
const port = Number(portStr) || 3100;
|
||||||
let auth: AuthConfig = { baseUrlMode: "auto" };
|
let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false };
|
||||||
if (deploymentMode === "authenticated" && exposure === "public") {
|
if (deploymentMode === "authenticated" && exposure === "public") {
|
||||||
const urlInput = await p.text({
|
const urlInput = await p.text({
|
||||||
message: "Public base URL",
|
message: "Public base URL",
|
||||||
@@ -139,11 +139,13 @@ export async function promptServer(opts?: {
|
|||||||
}
|
}
|
||||||
auth = {
|
auth = {
|
||||||
baseUrlMode: "explicit",
|
baseUrlMode: "explicit",
|
||||||
|
disableSignUp: false,
|
||||||
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
publicBaseUrl: urlInput.trim().replace(/\/+$/, ""),
|
||||||
};
|
};
|
||||||
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
|
} else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) {
|
||||||
auth = {
|
auth = {
|
||||||
baseUrlMode: "explicit",
|
baseUrlMode: "explicit",
|
||||||
|
disableSignUp: false,
|
||||||
publicBaseUrl: currentAuth.publicBaseUrl,
|
publicBaseUrl: currentAuth.publicBaseUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,196 +1,120 @@
|
|||||||
# Publishing to npm
|
# 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+
|
## Current Release Entry Points
|
||||||
- pnpm 9.15+
|
|
||||||
- An npm account with publish access to the `paperclipai` package
|
|
||||||
- Logged in to npm: `npm login`
|
|
||||||
|
|
||||||
## 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
|
## Why the CLI needs special packaging
|
||||||
./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
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
- `@paperclipai/server`
|
||||||
git push && git push origin v<version>
|
- `@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
|
Run:
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/build-npm.sh
|
./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.
|
1. Runs the forbidden token check unless `--skip-checks` is supplied
|
||||||
2. **TypeScript type-check** — runs `pnpm -r typecheck` across all workspace packages.
|
2. Runs `pnpm -r typecheck`
|
||||||
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.
|
3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
|
||||||
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).
|
4. Verifies the bundled entrypoint with `node --check`
|
||||||
5. **Summary** — prints the bundle size and next steps.
|
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
|
```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
|
The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||||
cd cli && npm pack --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Publish
|
Recommended CI release setup:
|
||||||
|
|
||||||
```bash
|
- use npm trusted publishing via GitHub OIDC
|
||||||
cd cli && npm publish --access public
|
- 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`:
|
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||||
|
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||||
```bash
|
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||||
mv cli/package.dev.json cli/package.json
|
- [`doc/RELEASING.md`](RELEASING.md)
|
||||||
```
|
|
||||||
|
|
||||||
### 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 |
|
|
||||||
|
|||||||
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",
|
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
|
||||||
"build:npm": "./scripts/build-npm.sh",
|
"build:npm": "./scripts/build-npm.sh",
|
||||||
"release": "./scripts/release.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",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"version-packages": "changeset version",
|
||||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||||
"docs:dev": "cd docs && npx mintlify dev",
|
"docs:dev": "cd docs && npx mintlify dev",
|
||||||
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
||||||
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.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": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.30.0",
|
"@changesets/cli": "^2.30.0",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vitest": "^3.0.5"
|
"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_TEST_ROOT: $PC_TEST_ROOT"
|
||||||
echo "PC_HOME: $PC_HOME"
|
echo "PC_HOME: $PC_HOME"
|
||||||
cd $PC_TEST_ROOT
|
cd $PC_TEST_ROOT
|
||||||
git clone github.com:paperclipai/paperclip.git repo
|
git clone https://github.com/paperclipai/paperclip.git repo
|
||||||
cd repo
|
cd repo
|
||||||
pnpm install
|
pnpm install
|
||||||
env HOME="$PC_HOME" npm_config_cache="$PC_CACHE" npm_config_userconfig="$PC_HOME/.npmrc" \
|
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
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# release.sh — One-command version bump, build, and publish via Changesets.
|
# release.sh — Prepare and publish a Paperclip release.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Stable release:
|
||||||
# ./scripts/release.sh patch # 0.2.0 → 0.2.1
|
# ./scripts/release.sh patch
|
||||||
# ./scripts/release.sh minor # 0.2.0 → 0.3.0
|
# ./scripts/release.sh minor --dry-run
|
||||||
# ./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
|
|
||||||
#
|
#
|
||||||
# Steps (normal):
|
# Canary release:
|
||||||
# 1. Preflight checks (clean tree, npm login)
|
# ./scripts/release.sh patch --canary
|
||||||
# 2. Auto-create a changeset for all public packages
|
# ./scripts/release.sh minor --canary --dry-run
|
||||||
# 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: Steps 1-5 unchanged, Step 6 publishes with --tag canary, Step 7 skipped.
|
# Canary releases publish prerelease versions such as 1.2.3-canary.0 under the
|
||||||
# --promote: Skips Steps 1-6, promotes canary to latest, then commits and tags.
|
# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest".
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
CLI_DIR="$REPO_ROOT/cli"
|
CLI_DIR="$REPO_ROOT/cli"
|
||||||
|
TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md"
|
||||||
# ── Helper: create GitHub Release ────────────────────────────────────────────
|
TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json"
|
||||||
create_github_release() {
|
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
|
||||||
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 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
dry_run=false
|
dry_run=false
|
||||||
canary=false
|
canary=false
|
||||||
promote=false
|
|
||||||
promote_version=""
|
|
||||||
bump_type=""
|
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
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--dry-run) dry_run=true ;;
|
--dry-run) dry_run=true ;;
|
||||||
--canary) canary=true ;;
|
--canary) canary=true ;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
--promote)
|
--promote)
|
||||||
promote=true
|
echo "Error: --promote was removed. Re-run a stable release from the vetted commit instead."
|
||||||
shift
|
exit 1
|
||||||
if [ $# -eq 0 ] || [[ "$1" == --* ]]; then
|
;;
|
||||||
echo "Error: --promote requires a version argument (e.g. --promote 0.2.8)"
|
*)
|
||||||
|
if [ -n "$bump_type" ]; then
|
||||||
|
echo "Error: only one bump type may be provided."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
promote_version="$1"
|
bump_type="$1"
|
||||||
;;
|
;;
|
||||||
*) bump_type="$1" ;;
|
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$promote" = true ] && [ "$canary" = true ]; then
|
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
|
||||||
echo "Error: --canary and --promote cannot be used together"
|
usage
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$promote" = false ]; then
|
info() {
|
||||||
if [ -z "$bump_type" ]; then
|
echo "$@"
|
||||||
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 {}
|
|
||||||
}
|
}
|
||||||
console.log(names.join('\n'));
|
|
||||||
")
|
|
||||||
|
|
||||||
echo ""
|
fail() {
|
||||||
echo " Promoting packages to @latest:"
|
echo "Error: $*" >&2
|
||||||
while IFS= read -r pkg; do
|
exit 1
|
||||||
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"
|
|
||||||
|
|
||||||
# Restore CLI dev package.json if present
|
restore_publish_artifacts() {
|
||||||
if [ -f "$CLI_DIR/package.dev.json" ]; then
|
if [ -f "$CLI_DIR/package.dev.json" ]; then
|
||||||
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
|
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
|
||||||
echo " ✓ Restored workspace dependencies in cli/package.json"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove the README copied for npm publishing
|
rm -f "$CLI_DIR/README.md"
|
||||||
if [ -f "$CLI_DIR/README.md" ]; then
|
|
||||||
rm "$CLI_DIR/README.md"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove temporary build artifacts
|
|
||||||
rm -rf "$REPO_ROOT/server/ui-dist"
|
rm -rf "$REPO_ROOT/server/ui-dist"
|
||||||
|
|
||||||
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
||||||
rm -rf "$REPO_ROOT/$pkg_dir/skills"
|
rm -rf "$REPO_ROOT/$pkg_dir/skills"
|
||||||
done
|
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
|
cleanup_release_state() {
|
||||||
CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md"
|
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 "---"
|
echo "---"
|
||||||
while IFS= read -r pkg; do
|
while IFS= read -r pkg_name; do
|
||||||
echo "\"$pkg\": $bump_type"
|
[ -z "$pkg_name" ] && continue
|
||||||
done <<< "$PACKAGES"
|
echo "\"$pkg_name\": $bump_type"
|
||||||
|
done <<< "$PUBLIC_PACKAGE_NAMES"
|
||||||
echo "---"
|
echo "---"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Version bump ($bump_type)"
|
if [ "$canary" = true ]; then
|
||||||
} > "$CHANGESET_FILE"
|
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"
|
info ""
|
||||||
|
info "==> Step 3/7: Versioning packages..."
|
||||||
# ── Step 3: Version packages ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "==> Step 3/7: Running changeset version..."
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
if [ "$canary" = true ]; then
|
||||||
|
npx changeset pre enter canary
|
||||||
|
fi
|
||||||
npx changeset version
|
npx changeset version
|
||||||
echo " ✓ Versions bumped and CHANGELOGs generated"
|
|
||||||
|
|
||||||
# Read the new version from the CLI package
|
if [ "$canary" = true ]; then
|
||||||
NEW_VERSION=$(node -e "console.log(require('$CLI_DIR/package.json').version)")
|
BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0"
|
||||||
echo " New version: $NEW_VERSION"
|
if [ "$TARGET_PUBLISH_VERSION" != "$BASE_CANARY_VERSION" ]; then
|
||||||
|
replace_version_string "$BASE_CANARY_VERSION" "$TARGET_PUBLISH_VERSION"
|
||||||
# Update the version string in cli/src/index.ts
|
fi
|
||||||
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"
|
|
||||||
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 ""
|
info ""
|
||||||
echo "==> Step 4/7: Building all packages..."
|
info "==> Step 4/7: Building workspace artifacts..."
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
pnpm build
|
||||||
# Build packages in dependency order (excluding CLI)
|
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
|
||||||
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)
|
|
||||||
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do
|
||||||
rm -rf "$REPO_ROOT/$pkg_dir/skills"
|
rm -rf "$REPO_ROOT/$pkg_dir/skills"
|
||||||
cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills"
|
cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills"
|
||||||
done
|
done
|
||||||
echo " ✓ All packages built (including UI + skills)"
|
info " ✓ Workspace build complete"
|
||||||
|
|
||||||
# ── Step 5: Build CLI bundle ─────────────────────────────────────────────────
|
info ""
|
||||||
|
info "==> Step 5/7: Building publishable CLI bundle..."
|
||||||
echo ""
|
|
||||||
echo "==> Step 5/7: Building CLI bundle..."
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks
|
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks
|
||||||
echo " ✓ CLI bundled"
|
info " ✓ CLI bundle ready"
|
||||||
|
|
||||||
# ── Step 6: Publish ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
info ""
|
||||||
if [ "$dry_run" = true ]; then
|
if [ "$dry_run" = true ]; then
|
||||||
echo ""
|
info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
|
||||||
if [ "$canary" = true ]; then
|
while IFS= read -r pkg_dir; do
|
||||||
echo "==> Step 6/7: Skipping publish (--dry-run, --canary)"
|
[ -z "$pkg_dir" ] && continue
|
||||||
else
|
info " --- $pkg_dir ---"
|
||||||
echo "==> Step 6/7: Skipping publish (--dry-run)"
|
cd "$REPO_ROOT/$pkg_dir"
|
||||||
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"
|
|
||||||
npm pack --dry-run 2>&1 | tail -3
|
npm pack --dry-run 2>&1 | tail -3
|
||||||
done
|
done <<< "$PUBLIC_PACKAGE_DIRS"
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
if [ "$canary" = true ]; then
|
if [ "$canary" = true ]; then
|
||||||
echo ""
|
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
|
||||||
echo " [dry-run] Would publish with: npx changeset publish --tag canary"
|
else
|
||||||
|
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo ""
|
|
||||||
if [ "$canary" = true ]; then
|
if [ "$canary" = true ]; then
|
||||||
echo "==> Step 6/7: Publishing to npm (canary)..."
|
info "==> Step 6/7: Publishing canary to npm..."
|
||||||
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"
|
|
||||||
npx changeset publish
|
npx changeset publish
|
||||||
echo " ✓ Published all packages"
|
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
|
||||||
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"
|
|
||||||
else
|
else
|
||||||
echo "Published canary at v$NEW_VERSION"
|
info "==> Step 6/7: Publishing stable release to npm..."
|
||||||
echo ""
|
npx changeset publish
|
||||||
echo "Verify: npm view paperclipai@canary version"
|
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||||
echo ""
|
|
||||||
echo "To promote to latest:"
|
|
||||||
echo " ./scripts/release.sh --promote $NEW_VERSION"
|
|
||||||
fi
|
fi
|
||||||
elif [ "$dry_run" = true ]; then
|
fi
|
||||||
echo "Dry run complete for v$NEW_VERSION."
|
|
||||||
echo " - Versions bumped, built, and previewed"
|
info ""
|
||||||
echo " - Dev package.json restored"
|
if [ "$dry_run" = true ]; then
|
||||||
echo " - Commit and tag created (locally)"
|
info "==> Step 7/7: Cleaning up dry-run state..."
|
||||||
echo " - Would create GitHub Release"
|
info " ✓ Dry run leaves the working tree unchanged"
|
||||||
echo ""
|
elif [ "$canary" = true ]; then
|
||||||
echo "To actually publish, run:"
|
info "==> Step 7/7: Cleaning up canary state..."
|
||||||
echo " ./scripts/release.sh $bump_type"
|
info " ✓ Canary state will be discarded after publish"
|
||||||
else
|
else
|
||||||
echo "Published all packages at v$NEW_VERSION"
|
info "==> Step 7/7: Finalizing stable release commit..."
|
||||||
echo ""
|
restore_publish_artifacts
|
||||||
echo "To push:"
|
|
||||||
echo " git push && git push origin v$NEW_VERSION"
|
git -C "$REPO_ROOT" add -u .changeset packages server cli
|
||||||
echo ""
|
if [ -f "$REPO_ROOT/releases/v${TARGET_STABLE_VERSION}.md" ]; then
|
||||||
echo "GitHub Release: https://github.com/cryppadotta/paperclip/releases/tag/v$NEW_VERSION"
|
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
|
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": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"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",
|
"build": "tsc",
|
||||||
|
"prepack": "pnpm run prepare:ui-dist",
|
||||||
|
"postpack": "rm -rf ui-dist",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -1,363 +1,140 @@
|
|||||||
---
|
---
|
||||||
name: release-changelog
|
name: release-changelog
|
||||||
description: >
|
description: >
|
||||||
Generate user-facing release changelogs for Paperclip. Reads git history,
|
Generate the stable Paperclip release changelog at releases/v{version}.md by
|
||||||
merged PRs, and changeset files since the last release tag. Detects breaking
|
reading commits, changesets, and merged PR context since the last stable tag.
|
||||||
changes, categorizes changes, and outputs structured markdown to
|
|
||||||
releases/v{version}.md. Use when preparing a release or when asked to
|
|
||||||
generate a changelog.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Release Changelog Skill
|
# Release Changelog Skill
|
||||||
|
|
||||||
Generate a user-facing changelog for a new Paperclip release. This skill reads
|
Generate the user-facing changelog for the **stable** Paperclip release.
|
||||||
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.
|
|
||||||
|
|
||||||
**Output:** `releases/v{version}.md` in the repo root.
|
Output:
|
||||||
**Review required:** Always present the draft for human sign-off before
|
|
||||||
finalizing. Never auto-publish.
|
|
||||||
|
|
||||||
---
|
- `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
|
## 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
|
```bash
|
||||||
ls releases/v{version}.md 2>/dev/null
|
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.
|
1. read it first
|
||||||
2. Ask: "A changelog for v{version} already exists. Do you want to (a) keep it
|
2. present it to the reviewer
|
||||||
as-is, (b) regenerate from scratch, or (c) update specific sections?"
|
3. ask whether to keep it, regenerate it, or update specific sections
|
||||||
3. If the reviewer says keep it → **stop here**. Do not overwrite. This skill is
|
4. never overwrite it silently
|
||||||
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.
|
|
||||||
|
|
||||||
**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
|
Find the last stable tag:
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Last release tag (most recent semver tag)
|
git tag --list 'v*' --sort=-version:refname | head -1
|
||||||
git tag --sort=-version:refname | head -1
|
git log v{last}..HEAD --oneline --no-merges
|
||||||
# e.g. v0.2.7
|
|
||||||
|
|
||||||
# All commits since that tag
|
|
||||||
git log v0.2.7..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 maintainer request
|
||||||
- An explicit argument (e.g. "generate changelog for v0.3.0")
|
- the chosen bump type applied to the last stable tag
|
||||||
- The bump type (patch/minor/major) applied to the last tag
|
- the release plan already agreed in `doc/RELEASING.md`
|
||||||
- The version already set in `cli/package.json` if `scripts/release.sh` has been run
|
|
||||||
|
|
||||||
---
|
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
|
```bash
|
||||||
git log v{last}..HEAD --oneline --no-merges
|
git log v{last}..HEAD --oneline --no-merges
|
||||||
git log v{last}..HEAD --format="%H %s" --no-merges # full SHAs for file diffs
|
git log v{last}..HEAD --format="%H %s" --no-merges
|
||||||
```
|
|
||||||
|
|
||||||
### 2b. Changeset Files
|
|
||||||
|
|
||||||
Look for unconsumed changesets in `.changeset/`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls .changeset/*.md | grep -v README.md
|
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
|
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
|
## Step 3 — Detect Breaking Changes
|
||||||
|
|
||||||
Scan for breaking changes using these signals. **Any match flags the release as
|
Look for:
|
||||||
containing breaking changes**, which affects version bump requirements and
|
|
||||||
changelog structure.
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
git diff --name-only v{last}..HEAD -- packages/db/src/migrations/
|
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/
|
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 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:
|
If the requested bump is lower than the minimum required bump, flag that before the release proceeds.
|
||||||
- Removed endpoints
|
|
||||||
- Changed request/response shapes (removed fields, type changes)
|
|
||||||
- Changed authentication requirements
|
|
||||||
|
|
||||||
### 3d. Config Changes
|
## Step 4 — Categorize for Users
|
||||||
|
|
||||||
```bash
|
Use these stable changelog sections:
|
||||||
git diff v{last}..HEAD -- cli/src/config/ packages/*/src/*config*
|
|
||||||
```
|
|
||||||
|
|
||||||
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:
|
## Step 5 — Write the File
|
||||||
- `BREAKING:` or `BREAKING CHANGE:` prefix
|
|
||||||
- `!` after the type in conventional commits (e.g. `feat!:`, `fix!:`)
|
|
||||||
|
|
||||||
### Version Bump Rules
|
Template:
|
||||||
|
|
||||||
| 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:
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# v{version}
|
# v{version}
|
||||||
|
|
||||||
> Released: {YYYY-MM-DD}
|
> Released: {YYYY-MM-DD}
|
||||||
|
|
||||||
{If breaking changes detected, include this section:}
|
|
||||||
|
|
||||||
## Breaking Changes
|
## 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
|
## Highlights
|
||||||
|
|
||||||
- **{Feature name}** — {1-2 sentence description of what it does and why it matters.}
|
|
||||||
|
|
||||||
## Improvements
|
## Improvements
|
||||||
|
|
||||||
- {Concise description of improvement}
|
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
|
|
||||||
- {Concise description of fix}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
{If breaking changes detected, include this section:}
|
|
||||||
|
|
||||||
## Upgrade Guide
|
## 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).
|
## Step 6 — Review Before Release
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
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:
|
This skill never publishes anything. It only prepares the stable changelog artifact.
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,402 +1,250 @@
|
|||||||
---
|
---
|
||||||
name: release
|
name: release
|
||||||
description: >
|
description: >
|
||||||
Coordinate a full Paperclip release across engineering, website publishing,
|
Coordinate a full Paperclip release across engineering verification, npm,
|
||||||
and social announcement. Use when CTO/CEO requests "do a release" or
|
GitHub, website publishing, and announcement follow-up. Use when leadership
|
||||||
"release vX.Y.Z". Runs pre-flight checks, generates changelog via
|
asks to ship a release, not merely to discuss version bumps.
|
||||||
release-changelog, executes npm release, creates cross-project follow-up
|
|
||||||
tasks, and posts a release wrap-up.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Release Coordination Skill
|
# Release Coordination Skill
|
||||||
|
|
||||||
Run the full Paperclip release process as an organizational workflow, not just
|
Run the full Paperclip release as a maintainer workflow, not just an npm publish.
|
||||||
an npm publish.
|
|
||||||
|
|
||||||
This skill coordinates:
|
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
|
## Trigger
|
||||||
|
|
||||||
Use this skill when leadership asks for:
|
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
|
## Preconditions
|
||||||
|
|
||||||
Before proceeding, verify all of the following:
|
Before proceeding, verify all of the following:
|
||||||
|
|
||||||
1. `skills/release-changelog/SKILL.md` exists and is usable.
|
1. `skills/release-changelog/SKILL.md` exists and is usable.
|
||||||
2. The `release-changelog` dependency work is complete/reviewed before running this flow.
|
2. The repo working tree is clean, including untracked files.
|
||||||
3. App repo working tree is clean.
|
3. There are commits since the last stable tag.
|
||||||
4. There are commits since the last release tag.
|
4. The release SHA has passed the verification gate or is about to.
|
||||||
5. You have release permissions (`npm whoami` succeeds for real publish).
|
5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`.
|
||||||
6. If running via Paperclip, you have issue context for posting status updates.
|
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.
|
If any precondition fails, stop and report the blocker.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
Collect these inputs up front:
|
Collect these inputs up front:
|
||||||
|
|
||||||
- Release request source issue (if in Paperclip)
|
- requested bump: `patch`, `minor`, or `major`
|
||||||
- Requested bump (`patch|minor|major`) or explicit version (`vX.Y.Z`)
|
- whether this run is a dry run or live release
|
||||||
- Whether this run is dry-run or live publish
|
- whether the release is being run locally or from GitHub Actions
|
||||||
- Company/project context for follow-up issue creation
|
- 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
|
1. Draft the **stable** changelog as `releases/vX.Y.Z.md`
|
||||||
any step, check whether it has already been completed:
|
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 |
|
Critical consequence:
|
||||||
|---|---|---|
|
|
||||||
| 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. |
|
|
||||||
|
|
||||||
**The golden rule:** If a git tag `v{version}` already exists, the release is
|
- Canaries do **not** use promote-by-dist-tag anymore.
|
||||||
fully promoted. Only post-publish tasks (website, CMO, wrap-up) should proceed.
|
- The changelog remains stable-only. Do not create `releases/vX.Y.Z-canary.N.md`.
|
||||||
If the version exists on npm but there's no git tag, the canary was published but
|
|
||||||
not yet promoted — resume from smoke test.
|
|
||||||
|
|
||||||
**Iterating on changelogs:** You can re-run this skill with an existing changelog
|
## Step 1 — Decide the Stable Version
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
Run release preflight first:
|
||||||
|
|
||||||
## Step 1 - Pre-flight and Version Decision
|
|
||||||
|
|
||||||
Run pre-flight in the App repo root:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
LAST_TAG=$(git tag --sort=-version:refname | head -1)
|
./scripts/release-preflight.sh canary {patch|minor|major}
|
||||||
git diff --quiet && git diff --cached --quiet
|
# or
|
||||||
git log "${LAST_TAG}..HEAD" --oneline --no-merges | head -50
|
./scripts/release-preflight.sh stable {patch|minor|major}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then detect minimum required bump:
|
Then use the last stable tag as the base:
|
||||||
|
|
||||||
```bash
|
```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/
|
git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/
|
||||||
|
|
||||||
# schema deltas
|
|
||||||
git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/
|
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
|
git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
|
||||||
```
|
```
|
||||||
|
|
||||||
Bump policy:
|
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
|
Invoke `release-changelog` and generate:
|
||||||
`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 the `release-changelog` skill and produce:
|
- `releases/vX.Y.Z.md`
|
||||||
- `releases/v{version}.md`
|
|
||||||
- Sections ordered as: Breaking Changes (if any), Highlights, Improvements, Fixes, Upgrade Guide (if any)
|
|
||||||
|
|
||||||
Required behavior:
|
Rules:
|
||||||
- Present the draft for human review.
|
|
||||||
- Flag ambiguous categorization items.
|
|
||||||
- Flag bump mismatches before publish.
|
|
||||||
- Do not publish until reviewer confirms.
|
|
||||||
|
|
||||||
---
|
- 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
|
Run the standard gate:
|
||||||
`latest` tag is never touched until the canary passes smoke testing.
|
|
||||||
|
|
||||||
**Idempotency check:** Before publishing, check if this version already exists
|
|
||||||
on npm:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if canary is already published
|
pnpm -r typecheck
|
||||||
npm view paperclipai@{version} version 2>/dev/null && echo "ALREADY_PUBLISHED" || echo "NOT_PUBLISHED"
|
pnpm test:run
|
||||||
|
pnpm build
|
||||||
# Also check git tag
|
|
||||||
git tag -l "v{version}"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- If a git tag exists → the release is already fully promoted. Skip to Step 6.
|
If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
# Dry run first
|
|
||||||
./scripts/release.sh {patch|minor|major} --canary --dry-run
|
./scripts/release.sh {patch|minor|major} --canary --dry-run
|
||||||
|
|
||||||
# Publish canary (after dry-run review)
|
|
||||||
./scripts/release.sh {patch|minor|major} --canary
|
./scripts/release.sh {patch|minor|major} --canary
|
||||||
```
|
```
|
||||||
|
|
||||||
This publishes all packages to npm with the `canary` dist-tag. The `latest` tag
|
What this means:
|
||||||
is **not** updated. Users running `npx paperclipai onboard` still get the
|
|
||||||
previous stable version.
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
npm view paperclipai@canary version
|
npm view paperclipai@canary version
|
||||||
# Should show the new version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**How `--canary` works in release.sh:**
|
The user install path is:
|
||||||
- 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
|
|
||||||
|
|
||||||
**Script changes required:** Add `--canary` support to `scripts/release.sh`:
|
```bash
|
||||||
- Parse `--canary` flag alongside `--dry-run`
|
npx paperclipai@canary onboard
|
||||||
- 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)
|
|
||||||
|
|
||||||
---
|
## Step 5 — Smoke Test the Canary
|
||||||
|
|
||||||
## Step 4 — Smoke Test the Canary
|
Run:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This builds a clean Ubuntu container, installs `paperclipai@canary` via npx, and
|
Confirm:
|
||||||
runs the onboarding flow. The UI is accessible at `http://localhost:3131`.
|
|
||||||
|
|
||||||
### 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
|
- stop the stable release
|
||||||
2. **Onboarding completes** — the wizard runs through without crashes
|
- fix the issue
|
||||||
3. **Server boots** — UI is accessible at the expected port
|
- publish another canary
|
||||||
4. **Basic operations** — can create a company, view the dashboard
|
- 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
|
## Step 6 — Publish Stable
|
||||||
in the Docker container's browser and verify key pages render
|
|
||||||
|
|
||||||
### If smoke test fails
|
Once the SHA is vetted, run:
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# For each published package, move the dist-tag from canary to latest
|
./scripts/release.sh {patch|minor|major} --dry-run
|
||||||
npm dist-tag add paperclipai@{version} latest
|
./scripts/release.sh {patch|minor|major}
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Script option:** Add `./scripts/release.sh --promote {version}` to automate
|
Stable publish does this:
|
||||||
the dist-tag promotion for all packages.
|
|
||||||
|
|
||||||
### 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
|
Stable publish does **not** push the release for you.
|
||||||
does, but was deferred during canary publish):
|
|
||||||
|
## Step 7 — Push and Create GitHub Release
|
||||||
|
|
||||||
|
After stable publish succeeds:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git push public-gh HEAD:master --follow-tags
|
||||||
git commit -m "chore: release v{version}"
|
./scripts/create-github-release.sh X.Y.Z
|
||||||
git tag "v{version}"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify promotion
|
Use the stable changelog file as the GitHub Release notes source.
|
||||||
|
|
||||||
```bash
|
## Step 8 — Finish the Other Surfaces
|
||||||
npm view paperclipai@latest version
|
|
||||||
# Should now show the new version
|
|
||||||
|
|
||||||
# Final sanity check
|
Create or verify follow-up work for:
|
||||||
npx --yes paperclipai@latest --version
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
- website changelog publishing
|
||||||
|
- launch post / social announcement
|
||||||
|
- any release summary in Paperclip issue context
|
||||||
|
|
||||||
## Step 6 - Create Cross-Project Follow-up Tasks
|
These should reference the stable release, not the canary.
|
||||||
|
|
||||||
**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`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Failure Handling
|
## Failure Handling
|
||||||
|
|
||||||
If blocked, update the release issue explicitly with:
|
If the canary is bad:
|
||||||
- what failed
|
|
||||||
- exact blocker
|
|
||||||
- who must act next
|
|
||||||
- whether any release artifacts were partially published
|
|
||||||
|
|
||||||
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 { queryKeys } from "../lib/queryKeys";
|
||||||
import { groupBy } from "../lib/groupBy";
|
import { groupBy } from "../lib/groupBy";
|
||||||
import { formatDate, cn } from "../lib/utils";
|
import { formatDate, cn } from "../lib/utils";
|
||||||
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
@@ -17,7 +18,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
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 { KanbanBoard } from "./KanbanBoard";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
|
||||||
@@ -233,24 +234,6 @@ export function IssuesList({
|
|||||||
|
|
||||||
const activeFilterCount = countActiveFilters(viewState);
|
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(() => {
|
const groupedContent = useMemo(() => {
|
||||||
if (viewState.groupBy === "none") {
|
if (viewState.groupBy === "none") {
|
||||||
return [{ key: "__all", label: null as string | null, items: filtered }];
|
return [{ key: "__all", label: null as string | null, items: filtered }];
|
||||||
@@ -608,149 +591,163 @@ export function IssuesList({
|
|||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={`/issues/${issue.identifier ?? 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) */}
|
{/* Status icon - left column on mobile, inline on desktop */}
|
||||||
<div className="w-3.5 shrink-0 hidden sm:block" />
|
<span className="shrink-0 pt-px sm:hidden" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||||
<div className="shrink-0" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
status={issue.status}
|
status={issue.status}
|
||||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<span className="text-sm text-muted-foreground font-mono shrink-0">
|
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate flex-1 min-w-0">{issue.title}</span>
|
|
||||||
{(issue.labels ?? []).length > 0 && (
|
{/* Right column on mobile: title + metadata stacked */}
|
||||||
<div className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
|
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
|
||||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
{/* Title line */}
|
||||||
<span
|
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
|
||||||
key={label.id}
|
{issue.title}
|
||||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
</span>
|
||||||
style={{
|
|
||||||
borderColor: label.color,
|
{/* Metadata line */}
|
||||||
color: label.color,
|
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||||
backgroundColor: `${label.color}1f`,
|
{/* 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>
|
||||||
{label.name}
|
<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>
|
||||||
|
{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>
|
||||||
))}
|
|
||||||
{(issue.labels ?? []).length > 3 && (
|
|
||||||
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<span className="text-xs text-muted-foreground sm:hidden">·</span>
|
||||||
)}
|
<span className="text-xs text-muted-foreground sm:hidden">
|
||||||
<div className="flex items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
{timeAgo(issue.updatedAt)}
|
||||||
{liveIssueIds?.has(issue.id) && (
|
</span>
|
||||||
<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>
|
||||||
<span className="relative flex h-2 w-2">
|
</span>
|
||||||
<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" />
|
{/* Desktop-only trailing content */}
|
||||||
</span>
|
<span className="hidden sm:flex sm:order-3 items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
|
{(issue.labels ?? []).length > 0 && (
|
||||||
|
<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}
|
||||||
|
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
borderColor: label.color,
|
||||||
|
color: label.color,
|
||||||
|
backgroundColor: `${label.color}1f`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(issue.labels ?? []).length > 3 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="hidden sm:block">
|
<Popover
|
||||||
<Popover
|
open={assigneePickerIssueId === issue.id}
|
||||||
open={assigneePickerIssueId === issue.id}
|
onOpenChange={(open) => {
|
||||||
onOpenChange={(open) => {
|
setAssigneePickerIssueId(open ? issue.id : null);
|
||||||
setAssigneePickerIssueId(open ? issue.id : null);
|
if (!open) setAssigneeSearch("");
|
||||||
if (!open) setAssigneeSearch("");
|
}}
|
||||||
}}
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||||
|
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
Assignee
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-56 p-1"
|
||||||
|
align="end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<input
|
||||||
|
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
||||||
|
placeholder="Search agents..."
|
||||||
|
value={assigneeSearch}
|
||||||
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
<button
|
<button
|
||||||
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
|
!issue.assigneeAgentId && "bg-accent"
|
||||||
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
assignIssue(issue.id, null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
No assignee
|
||||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
|
|
||||||
<User className="h-3 w-3" />
|
|
||||||
</span>
|
|
||||||
Assignee
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
{(agents ?? [])
|
||||||
<PopoverContent
|
.filter((agent) => {
|
||||||
className="w-56 p-1"
|
if (!assigneeSearch.trim()) return true;
|
||||||
align="end"
|
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
||||||
onClick={(e) => e.stopPropagation()}
|
})
|
||||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
.map((agent) => (
|
||||||
>
|
<button
|
||||||
<input
|
key={agent.id}
|
||||||
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
|
className={cn(
|
||||||
placeholder="Search agents..."
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
||||||
value={assigneeSearch}
|
issue.assigneeAgentId === agent.id && "bg-accent"
|
||||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
)}
|
||||||
autoFocus
|
onClick={(e) => {
|
||||||
/>
|
e.preventDefault();
|
||||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
e.stopPropagation();
|
||||||
<button
|
assignIssue(issue.id, agent.id);
|
||||||
className={cn(
|
}}
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
>
|
||||||
!issue.assigneeAgentId && "bg-accent"
|
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||||
)}
|
</button>
|
||||||
onClick={(e) => {
|
))}
|
||||||
e.preventDefault();
|
</div>
|
||||||
e.stopPropagation();
|
</PopoverContent>
|
||||||
assignIssue(issue.id, null);
|
</Popover>
|
||||||
}}
|
<span className="text-xs text-muted-foreground">
|
||||||
>
|
|
||||||
No assignee
|
|
||||||
</button>
|
|
||||||
{(agents ?? [])
|
|
||||||
.filter((agent) => {
|
|
||||||
if (!assigneeSearch.trim()) return true;
|
|
||||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
|
||||||
})
|
|
||||||
.map((agent) => (
|
|
||||||
<button
|
|
||||||
key={agent.id}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
|
||||||
issue.assigneeAgentId === agent.id && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
assignIssue(issue.id, agent.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
|
||||||
{formatDate(issue.createdAt)}
|
{formatDate(issue.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -831,7 +831,7 @@ export function NewIssueDialog() {
|
|||||||
placeholder="Add description..."
|
placeholder="Add description..."
|
||||||
bordered={false}
|
bordered={false}
|
||||||
mentions={mentionOptions}
|
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) => {
|
imageUploadHandler={async (file) => {
|
||||||
const asset = await uploadDescriptionImage.mutateAsync(file);
|
const asset = await uploadDescriptionImage.mutateAsync(file);
|
||||||
return asset.contentPath;
|
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 { EntityRow } from "../components/EntityRow";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||||
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -1747,6 +1748,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen
|
|||||||
|
|
||||||
{/* Log viewer */}
|
{/* Log viewer */}
|
||||||
<LogViewer run={run} adapterType={adapterType} />
|
<LogViewer run={run} adapterType={adapterType} />
|
||||||
|
<ScrollToBottom />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,26 +313,36 @@ export function Dashboard() {
|
|||||||
<Link
|
<Link
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={`/issues/${issue.identifier ?? 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 sm:items-center sm:gap-3">
|
||||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
{/* Status icon - left column on mobile */}
|
||||||
<div className="flex items-center gap-2 shrink-0 mt-0.5">
|
<span className="shrink-0 sm:hidden">
|
||||||
<PriorityIcon priority={issue.priority} />
|
<StatusIcon status={issue.status} />
|
||||||
<StatusIcon status={issue.status} />
|
</span>
|
||||||
</div>
|
|
||||||
<p className="min-w-0 flex-1 truncate">
|
{/* Right column on mobile: title + metadata stacked */}
|
||||||
<span>{issue.title}</span>
|
<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 && (() => {
|
{issue.assigneeAgentId && (() => {
|
||||||
const name = agentName(issue.assigneeAgentId);
|
const name = agentName(issue.assigneeAgentId);
|
||||||
return name
|
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;
|
: null;
|
||||||
})()}
|
})()}
|
||||||
</p>
|
<span className="text-xs text-muted-foreground sm:hidden">·</span>
|
||||||
</div>
|
<span className="text-xs text-muted-foreground shrink-0 sm:order-last">
|
||||||
<span className="text-xs text-muted-foreground shrink-0 pt-0.5">
|
{timeAgo(issue.updatedAt)}
|
||||||
{timeAgo(issue.updatedAt)}
|
</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -841,38 +841,44 @@ export function Inbox() {
|
|||||||
{staleIssues.map((issue) => (
|
{staleIssues.map((issue) => (
|
||||||
<div
|
<div
|
||||||
key={issue.id}
|
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
|
<Link
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
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" />
|
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
|
||||||
<PriorityIcon priority={issue.priority} />
|
{issue.title}
|
||||||
<StatusIcon status={issue.status} />
|
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
|
||||||
{issue.assigneeAgentId &&
|
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
|
||||||
(() => {
|
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
|
||||||
const name = agentName(issue.assigneeAgentId);
|
<span className="shrink-0 text-xs font-mono text-muted-foreground">
|
||||||
return name ? (
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
<Identity name={name} size="sm" />
|
</span>
|
||||||
) : (
|
{issue.assigneeAgentId &&
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
(() => {
|
||||||
{issue.assigneeAgentId.slice(0, 8)}
|
const name = agentName(issue.assigneeAgentId);
|
||||||
</span>
|
return name ? (
|
||||||
);
|
<span className="hidden sm:inline-flex"><Identity name={name} size="sm" /></span>
|
||||||
})()}
|
) : null;
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
})()}
|
||||||
updated {timeAgo(issue.updatedAt)}
|
<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>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => dismiss(`stale:${issue.id}`)}
|
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"
|
aria-label="Dismiss"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
@@ -896,47 +902,94 @@ export function Inbox() {
|
|||||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||||
const isFading = fadingOutIssues.has(issue.id);
|
const isFading = fadingOutIssues.has(issue.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<Link
|
||||||
key={issue.id}
|
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">
|
{/* Status icon - left column on mobile, inline on desktop */}
|
||||||
{(isUnread || isFading) && (
|
<span className="shrink-0 sm:hidden">
|
||||||
<button
|
<StatusIcon status={issue.status} />
|
||||||
type="button"
|
</span>
|
||||||
onClick={(e) => {
|
|
||||||
|
{/* 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);
|
||||||
|
}}
|
||||||
|
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 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
|
||||||
|
isFading ? "opacity-0" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<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="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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
markReadMutation.mutate(issue.id);
|
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"
|
}}
|
||||||
aria-label="Mark as read"
|
className="shrink-0 self-center cursor-pointer sm:hidden"
|
||||||
>
|
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 ${
|
<span
|
||||||
isFading ? "opacity-0" : "opacity-100"
|
className={`block 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="text-xs font-mono text-muted-foreground">
|
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
)}
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
</Link>
|
||||||
{issue.lastExternalCommentAt
|
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
|
||||||
: `updated ${timeAgo(issue.updatedAt)}`}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { CommentThread } from "../components/CommentThread";
|
|||||||
import { IssueProperties } from "../components/IssueProperties";
|
import { IssueProperties } from "../components/IssueProperties";
|
||||||
import { LiveRunWidget } from "../components/LiveRunWidget";
|
import { LiveRunWidget } from "../components/LiveRunWidget";
|
||||||
import type { MentionOption } from "../components/MarkdownEditor";
|
import type { MentionOption } from "../components/MarkdownEditor";
|
||||||
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
import { StatusIcon } from "../components/StatusIcon";
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
import { PriorityIcon } from "../components/PriorityIcon";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
@@ -926,6 +927,7 @@ export function IssueDetail() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
<ScrollToBottom />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user