Merge pull request #1151 from paperclipai/release-automation-calver

Automate canary/stable releases with CalVer
This commit is contained in:
Dotta
2026-03-17 14:46:26 -05:00
committed by GitHub
20 changed files with 2174 additions and 1268 deletions

View File

@@ -1,8 +0,0 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [["@paperclipai/*", "paperclipai"]],
"linked": [],
"access": "public",
"baseBranch": "master",
"updateInternalDependencies": "patch",
"ignore": ["@paperclipai/ui"]
}

10
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,10 @@
# Replace @dotta if a different maintainer or team should own release infrastructure.
.github/workflows/release.yml @dotta
scripts/release*.sh @dotta
scripts/release-*.mjs @dotta
scripts/create-github-release.sh @dotta
scripts/rollback-latest.sh @dotta
doc/RELEASING.md @dotta
doc/PUBLISHING.md @dotta
doc/RELEASE-AUTOMATION-SETUP.md @dotta

View File

@@ -1,38 +1,33 @@
name: Release
on:
push:
branches:
- master
workflow_dispatch:
inputs:
channel:
description: Release channel
source_ref:
description: Commit SHA, branch, or tag to publish as stable
required: true
type: choice
default: canary
options:
- canary
- stable
bump:
description: Semantic version bump
required: true
type: choice
default: patch
options:
- patch
- minor
- major
type: string
default: master
stable_date:
description: Stable release date in UTC (YYYY-MM-DD). Defaults to today.
required: false
type: string
dry_run:
description: Preview the release without publishing
description: Preview the stable release without publishing
required: true
type: boolean
default: true
default: false
concurrency:
group: release-${{ github.ref }}
group: release-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: false
jobs:
verify:
if: startsWith(github.ref, 'refs/heads/release/')
verify_canary:
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
@@ -56,7 +51,7 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
@@ -67,12 +62,12 @@ jobs:
- name: Build
run: pnpm build
publish:
if: startsWith(github.ref, 'refs/heads/release/')
needs: verify
publish_canary:
if: github.event_name == 'push'
needs: verify_canary
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-release
environment: npm-canary
permissions:
contents: write
id-token: write
@@ -95,32 +90,159 @@ jobs:
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Run release script
- name: Publish canary
env:
GITHUB_ACTIONS: "true"
run: ./scripts/release.sh canary --skip-verify
- name: Push canary tag
run: |
tag="$(git tag --points-at HEAD | grep '^canary/v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no canary tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
verify_stable:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
preview_stable:
if: github.event_name == 'workflow_dispatch' && inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Dry-run stable release
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")
args=(stable --skip-verify --dry-run)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
- name: Push stable release branch commit and tag
if: inputs.channel == 'stable' && !inputs.dry_run
run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags
publish_stable:
if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-stable
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ inputs.source_ref }}
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Configure git author
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Publish stable
env:
GITHUB_ACTIONS: "true"
run: |
args=(stable --skip-verify)
if [ -n "${{ inputs.stable_date }}" ]; then
args+=(--date "${{ inputs.stable_date }}")
fi
./scripts/release.sh "${args[@]}"
- name: Push stable tag
run: |
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
if [ -z "$tag" ]; then
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
git push origin "refs/tags/${tag}"
- name: Create GitHub Release
if: inputs.channel == 'stable' && !inputs.dry_run
env:
GH_TOKEN: ${{ github.token }}
run: |

View File

@@ -1,18 +1,19 @@
# Publishing to npm
Low-level reference for how Paperclip packages are built for npm.
Low-level reference for how Paperclip packages are prepared and published to npm.
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.
For the maintainer workflow, use [doc/RELEASING.md](RELEASING.md). This document focuses on packaging internals.
## Current Release Entry Points
Use these scripts instead of older one-off publish commands:
Use these scripts:
- [`scripts/release-start.sh`](../scripts/release-start.sh) to create or resume `release/X.Y.Z`
- [`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 pushing the stable branch tag
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable publish flows
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing a stable tag
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest`
- [`scripts/build-npm.sh`](../scripts/build-npm.sh) for the CLI packaging build
Paperclip no longer uses release branches or Changesets for publishing.
## Why the CLI needs special packaging
@@ -23,7 +24,7 @@ The CLI package, `paperclipai`, imports code from workspace packages such as:
- `@paperclipai/shared`
- adapter packages under `packages/adapters/`
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.
Those workspace references are valid in development but not in a publishable npm package. The release flow rewrites versions temporarily, then builds a publishable CLI bundle.
## `build-npm.sh`
@@ -33,89 +34,107 @@ Run:
./scripts/build-npm.sh
```
This script does six things:
This script:
1. Runs the forbidden token check unless `--skip-checks` is supplied
2. Runs `pnpm -r typecheck`
3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
4. Verifies the bundled entrypoint with `node --check`
5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json`
6. Copies the repo `README.md` into `cli/README.md` for npm package metadata
1. runs the forbidden token check unless `--skip-checks` is supplied
2. runs `pnpm -r typecheck`
3. bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
4. verifies the bundled entrypoint with `node --check`
5. rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json`
6. copies the repo `README.md` into `cli/README.md` for npm metadata
`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies.
After the release script exits, the dev manifest and temporary files are restored automatically.
## Publishable CLI layout
## Package discovery and versioning
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:
Public packages are discovered from:
- `packages/`
- `server/`
- `cli/`
`ui/` remains ignored for npm publishing because it is private.
`ui/` is ignored because it is private.
This matters because all public packages are versioned and published together as one release unit.
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
## Canary packaging model
- finds all public packages
- sorts them topologically by internal dependencies
- rewrites each package version to the target release version
- rewrites internal `workspace:*` dependency references to the exact target version
- updates the CLI's displayed version string
Canaries are published as semver prereleases such as:
Those rewrites are temporary. The working tree is restored after publish or dry-run.
- `1.2.3-canary.0`
- `1.2.3-canary.1`
## Version formats
They are published under the npm dist-tag `canary`.
Paperclip uses calendar versions:
This means:
- stable: `YYYY.M.D`
- canary: `YYYY.M.D-canary.N`
- `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`
Examples:
## Stable packaging model
- stable: `2026.3.17`
- canary: `2026.3.17-canary.2`
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
## Publish model
The stable publish flow also creates the local release commit and git tag on `release/X.Y.Z`. Pushing that branch commit/tag, creating the GitHub Release, and merging the release branch back to `master` happen afterward as separate maintainer steps.
### Canary
Canaries publish under the npm dist-tag `canary`.
Example:
- `paperclipai@2026.3.17-canary.2`
This keeps the default install path unchanged while allowing explicit installs with:
```bash
npx paperclipai@canary onboard
```
### Stable
Stable publishes use the npm dist-tag `latest`.
Example:
- `paperclipai@2026.3.17`
Stable publishes do not create a release commit. Instead:
- package versions are rewritten temporarily
- packages are published from the chosen source commit
- git tag `vYYYY.M.D` points at that original commit
## Trusted publishing
The intended CI model is npm trusted publishing through GitHub OIDC.
That means:
- no long-lived `NPM_TOKEN` in repository secrets
- GitHub Actions obtains short-lived publish credentials
- trusted publisher rules are configured per workflow file
See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps.
## Rollback model
Rollback does not unpublish packages.
Rollback does not unpublish anything.
Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with:
It repoints the `latest` dist-tag to a prior stable version:
```bash
./scripts/rollback-latest.sh <stable-version>
./scripts/rollback-latest.sh 2026.3.16
```
That keeps history intact while restoring the default install path quickly.
## Notes for CI
The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
Recommended CI release setup:
- use npm trusted publishing via GitHub OIDC
- require approval through the `npm-release` environment
- run releases from `release/X.Y.Z`
- use canary first, then stable
This is the fastest way to restore the default install path if a stable release is bad.
## Related Files
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
- [`doc/RELEASING.md`](RELEASING.md)

View File

@@ -0,0 +1,273 @@
# Release Automation Setup
This document covers the GitHub and npm setup required for the current Paperclip release model:
- automatic canaries from `master`
- manual stable promotion from a chosen source ref
- npm trusted publishing via GitHub OIDC
- protected release infrastructure in a public repository
Repo-side files that depend on this setup:
- `.github/workflows/release.yml`
- `.github/CODEOWNERS`
Note:
- the release workflows intentionally use `pnpm install --no-frozen-lockfile`
- this matches the repo's current policy where `pnpm-lock.yaml` is refreshed by GitHub automation after manifest changes land on `master`
## 1. Merge the Repo Changes First
Before touching GitHub or npm settings, merge the release automation code so the referenced workflow filenames already exist on the default branch.
Required files:
- `.github/workflows/release.yml`
- `.github/CODEOWNERS`
## 2. Configure npm Trusted Publishing
Do this for every public package that Paperclip publishes.
At minimum that includes:
- `paperclipai`
- `@paperclipai/server`
- public packages under `packages/`
### 2.1. In npm, open each package settings page
For each package:
1. open npm as an owner of the package
2. go to the package settings / publishing access area
3. add a trusted publisher for the GitHub repository `paperclipai/paperclip`
### 2.2. Add one trusted publisher entry per package
npm currently allows one trusted publisher configuration per package.
Configure:
- workflow: `.github/workflows/release.yml`
Repository:
- `paperclipai/paperclip`
Environment name:
- leave the npm trusted-publisher environment field blank
Why:
- the single `release.yml` workflow handles both canary and stable publishing
- GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side
### 2.3. Verify trusted publishing before removing old auth
After the workflows are live:
1. run a canary publish
2. confirm npm publish succeeds without any `NPM_TOKEN`
3. run a stable dry-run
4. run one real stable publish
Only after that should you remove old token-based access.
## 3. Remove Legacy npm Tokens
After trusted publishing works:
1. revoke any repository or organization `NPM_TOKEN` secrets used for publish
2. revoke any personal automation token that used to publish Paperclip
3. if npm offers a package-level setting to restrict publishing to trusted publishers, enable it
Goal:
- no long-lived npm publishing token should remain in GitHub Actions
## 4. Create GitHub Environments
Create two environments in the GitHub repository:
- `npm-canary`
- `npm-stable`
Path:
1. GitHub repository
2. `Settings`
3. `Environments`
4. `New environment`
## 5. Configure `npm-canary`
Recommended settings for `npm-canary`:
- environment name: `npm-canary`
- required reviewers: none
- wait timer: none
- deployment branches and tags:
- selected branches only
- allow `master`
Reasoning:
- every push to `master` should be able to publish a canary automatically
- no human approval should be required for canaries
## 6. Configure `npm-stable`
Recommended settings for `npm-stable`:
- environment name: `npm-stable`
- required reviewers: at least one maintainer other than the person triggering the workflow when possible
- prevent self-review: enabled
- admin bypass: disabled if your team can tolerate it
- wait timer: optional
- deployment branches and tags:
- selected branches only
- allow `master`
Reasoning:
- stable publishes should require an explicit human approval gate
- the workflow is manual, but the environment should still be the real control point
## 7. Protect `master`
Open the branch protection settings for `master`.
Recommended rules:
1. require pull requests before merging
2. require status checks to pass before merging
3. require review from code owners
4. dismiss stale approvals when new commits are pushed
5. restrict who can push directly to `master`
At minimum, make sure workflow and release script changes cannot land without review.
## 8. Enforce CODEOWNERS Review
This repo now includes `.github/CODEOWNERS`, but GitHub only enforces it if branch protection requires code owner reviews.
In branch protection for `master`, enable:
- `Require review from Code Owners`
Then verify the owner entries are correct for your actual maintainer set.
Current file:
- `.github/CODEOWNERS`
If `@dotta` is not the right reviewer identity in the public repo, change it before enabling enforcement.
## 9. Protect Release Infrastructure Specifically
These files should always trigger code owner review:
- `.github/workflows/release.yml`
- `scripts/release.sh`
- `scripts/release-lib.sh`
- `scripts/release-package-map.mjs`
- `scripts/create-github-release.sh`
- `scripts/rollback-latest.sh`
- `doc/RELEASING.md`
- `doc/PUBLISHING.md`
If you want stronger controls, add a repository ruleset that explicitly blocks direct pushes to:
- `.github/workflows/**`
- `scripts/release*`
## 10. Do Not Store a Claude Token in GitHub Actions
Do not add a personal Claude or Anthropic token for automatic changelog generation.
Recommended policy:
- stable changelog generation happens locally from a trusted maintainer machine
- canaries never generate changelogs
This keeps LLM spending intentional and avoids a high-value token sitting in Actions.
## 11. Verify the Canary Workflow
After setup:
1. merge a harmless commit to `master`
2. open the `Release` workflow run triggered by that push
3. confirm it passes verification
4. confirm publish succeeds under the `npm-canary` environment
5. confirm npm now shows a new `canary` release
6. confirm a git tag named `canary/vYYYY.M.D-canary.N` was pushed
Install-path check:
```bash
npx paperclipai@canary onboard
```
## 12. Verify the Stable Workflow
After at least one good canary exists:
1. prepare `releases/vYYYY.M.D.md` on the source commit you want to promote
2. open `Actions` -> `Release`
3. run it with:
- `source_ref`: the tested commit SHA or canary tag source commit
- `stable_date`: leave blank or set the intended UTC date
- `dry_run`: `true`
4. confirm the dry-run succeeds
5. rerun with `dry_run: false`
6. approve the `npm-stable` environment when prompted
7. confirm npm `latest` points to the new stable version
8. confirm git tag `vYYYY.M.D` exists
9. confirm the GitHub Release was created
## 13. Suggested Maintainer Policy
Use this policy going forward:
- canaries are automatic and cheap
- stables are manual and approved
- only stables get public notes and announcements
- release notes are committed before stable publish
- rollback uses `npm dist-tag`, not unpublish
## 14. Troubleshooting
### Trusted publishing fails with an auth error
Check:
1. the workflow filename on GitHub exactly matches the filename configured in npm
2. the package has the trusted publisher entry for the correct repository
3. the job has `id-token: write`
4. the job is running from the expected repository, not a fork
### Stable workflow runs but never asks for approval
Check:
1. the `publish` job uses environment `npm-stable`
2. the environment actually has required reviewers configured
3. the workflow is running in the canonical repository, not a fork
### CODEOWNERS does not trigger
Check:
1. `.github/CODEOWNERS` is on the default branch
2. branch protection on `master` requires code owner review
3. the owner identities in the file are valid reviewers with repository access
## Related Docs
- [doc/RELEASING.md](RELEASING.md)
- [doc/PUBLISHING.md](PUBLISHING.md)
- [doc/plans/2026-03-17-release-automation-and-versioning.md](plans/2026-03-17-release-automation-and-versioning.md)

View File

@@ -1,74 +1,66 @@
# Releasing Paperclip
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
Maintainer runbook for shipping Paperclip across npm, GitHub, and the website-facing changelog surface.
The release model is branch-driven:
The release model is now commit-driven:
1. Start a release train on `release/X.Y.Z`
2. Draft the stable changelog on that branch
3. Publish one or more canaries from that branch
4. Publish stable from that same branch head
5. Push the branch commit and tag
6. Create the GitHub Release
7. Merge `release/X.Y.Z` back to `master` without squash or rebase
1. Every push to `master` publishes a canary automatically.
2. Stable releases are manually promoted from a chosen tested commit or canary tag.
3. Stable release notes live in `releases/vYYYY.M.D.md`.
4. Only stable releases get GitHub Releases.
## Versioning Model
Paperclip uses calendar versions that still fit semver syntax:
- stable: `YYYY.M.D`
- canary: `YYYY.M.D-canary.N`
Examples:
- stable on March 17, 2026: `2026.3.17`
- fourth canary on March 17, 2026: `2026.3.17-canary.3`
Important constraints:
- do not use leading zeroes such as `2026.03.17`
- do not use four numeric segments such as `2026.03.17.1`
- the semver-safe canary form is `2026.3.17-canary.1`
## Release Surfaces
Every release has four separate surfaces:
Every stable release has four separate surfaces:
1. **Verification** — the exact git SHA passes typecheck, tests, and build
2. **npm**`paperclipai` and public workspace packages are published
3. **GitHub** — the stable release gets a git tag and GitHub Release
4. **Website / announcements** — the stable changelog is published externally and announced
A release is done only when all four surfaces are handled.
A stable release is done only when all four surfaces are handled.
Canaries only cover the first two surfaces plus an internal traceability tag.
## Core Invariants
- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch.
- The release scripts must run from the matching `release/X.Y.Z` branch.
- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen.
- Do not squash-merge or rebase-merge a release branch PR back to `master`.
- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files.
The reason for the merge rule is simple: the tag must keep pointing at the exact published commit. Squash or rebase breaks that property.
- canaries publish from `master`
- stables publish from an explicitly chosen source ref
- tags point at the original source commit, not a generated release commit
- stable notes are always `releases/vYYYY.M.D.md`
- canaries never create GitHub Releases
- canaries never require changelog generation
## TL;DR
### 1. Start the release train
### Canary
Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub.
Every push to `master` runs the canary path inside [`.github/workflows/release.yml`](../.github/workflows/release.yml).
```bash
./scripts/release-start.sh patch
```
It:
That script:
- fetches the release remote and tags
- computes the next stable version from the latest `v*` tag
- creates or resumes `release/X.Y.Z`
- creates or resumes a dedicated worktree
- pushes the branch to the remote by default
- refuses to reuse a frozen release train
### 2. Draft the stable changelog
From the release worktree:
```bash
VERSION=X.Y.Z
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 .agents/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. Verify and publish a canary
```bash
./scripts/release-preflight.sh canary patch
./scripts/release.sh patch --canary --dry-run
./scripts/release.sh patch --canary
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
- verifies the pushed commit
- computes the canary version for the current UTC date
- publishes under npm dist-tag `canary`
- creates a git tag `canary/vYYYY.M.D-canary.N`
Users install canaries with:
@@ -76,145 +68,91 @@ Users install canaries with:
npx paperclipai@canary onboard
```
### 4. Publish stable
### Stable
Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the Actions tab with the manual `workflow_dispatch` inputs.
Inputs:
- `source_ref`
- commit SHA, branch, or tag
- `stable_date`
- optional UTC date override in `YYYY-MM-DD`
- `dry_run`
- preview only when true
Before running stable:
1. pick the canary commit or tag you trust
2. create or update `releases/vYYYY.M.D.md` on that source ref
3. run the stable workflow from that source ref
The workflow:
- re-verifies the exact source ref
- publishes `YYYY.M.D` under npm dist-tag `latest`
- creates git tag `vYYYY.M.D`
- creates or updates the GitHub Release from `releases/vYYYY.M.D.md`
## Local Commands
### Preview a canary locally
```bash
./scripts/release-preflight.sh stable patch
./scripts/release.sh patch --dry-run
./scripts/release.sh patch
git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z
./scripts/release.sh canary --dry-run
```
Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase.
## Release Branches
Paperclip uses one release branch per target stable version:
- `release/0.3.0`
- `release/0.3.1`
- `release/1.0.0`
Do not create separate per-canary branches like `canary/0.3.0-1`. A canary is just a prerelease snapshot of the same stable train.
## Script Entry Points
- [`scripts/release-start.sh`](../scripts/release-start.sh) — create or resume the release train branch/worktree
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — validate branch, version plan, git/npm state, and verification gate
- [`scripts/release.sh`](../scripts/release.sh) — publish canary or stable from the release branch
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after pushing the tag
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable version
## Detailed Workflow
### 1. Start or resume the release train
Run:
### Preview a stable locally
```bash
./scripts/release-start.sh <patch|minor|major>
./scripts/release.sh stable --dry-run
```
Useful options:
### Publish a stable locally
This is mainly for emergency/manual use. The normal path is the GitHub workflow.
```bash
./scripts/release-start.sh patch --dry-run
./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0
./scripts/release-start.sh patch --no-push
./scripts/release.sh stable
git push public-gh refs/tags/vYYYY.M.D
./scripts/create-github-release.sh YYYY.M.D
```
The script is intentionally idempotent:
## Stable Changelog Workflow
- if `release/X.Y.Z` already exists locally, it reuses it
- if the branch already exists on the remote, it resumes it locally
- if the branch is already checked out in another worktree, it points you there
- if `vX.Y.Z` already exists locally, remotely, or on npm, it refuses to reuse that train
Stable changelog files live at:
### 2. Write the stable changelog early
- `releases/vYYYY.M.D.md`
Create or update:
Canaries do not get changelog files.
- `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
- `Contributors` — @-mention every contributor by GitHub username (no emails)
Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative.
### 3. Run release preflight
From the `release/X.Y.Z` worktree:
Recommended local generation flow:
```bash
./scripts/release-preflight.sh canary <patch|minor|major>
# or
./scripts/release-preflight.sh stable <patch|minor|major>
VERSION=2026.3.17
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 .agents/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."
```
The preflight script now checks all of the following before it runs the verification gate:
The repo intentionally does not run this through GitHub Actions because:
- the worktree is clean, including untracked files
- the current branch matches the computed `release/X.Y.Z`
- the release train is not frozen
- the target version is still free on npm
- the target tag does not already exist locally or remotely
- whether the remote release branch already exists
- whether `releases/vX.Y.Z.md` is present
- canaries are too frequent
- stable notes are the only public narrative surface that needs LLM help
- maintainer LLM tokens should not live in Actions
Then it runs:
## Smoke Testing
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```
### 4. Publish one or more canaries
Run:
```bash
./scripts/release.sh <patch|minor|major> --canary --dry-run
./scripts/release.sh <patch|minor|major> --canary
```
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 worktree returns to clean after the script finishes
Guardrails:
- the script refuses to run from the wrong branch
- the script refuses to publish from a frozen train
- the canary is always derived from the next stable version
- if the stable notes file is missing, the script warns before you forget it
Concrete example:
- if the latest stable is `0.2.7`, a patch canary targets `0.2.8-canary.0`
- `0.2.7-canary.N` is invalid because `0.2.7` is already stable
### 5. Smoke test the canary
Run the actual install path in Docker:
For a canary:
```bash
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
For the current stable:
```bash
PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
```
Useful isolated variants:
```bash
@@ -222,14 +160,6 @@ HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary .
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
```
If you want to exercise onboarding from the current committed ref instead of npm, use:
```bash
./scripts/clean-onboard-ref.sh
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
```
Minimum checks:
- `npx paperclipai@canary onboard` installs
@@ -238,185 +168,59 @@ Minimum checks:
- the UI loads
- basic company creation and dashboard load work
If smoke testing fails:
## Rollback
1. stop the stable release
2. fix the issue on the same `release/X.Y.Z` branch
3. publish another canary
4. rerun smoke testing
Rollback does not unpublish versions.
### 6. Publish stable from the same release branch
Once the branch head is vetted, run:
It only moves the `latest` dist-tag back to a previous stable:
```bash
./scripts/release.sh <patch|minor|major> --dry-run
./scripts/release.sh <patch|minor|major>
./scripts/rollback-latest.sh 2026.3.16 --dry-run
./scripts/rollback-latest.sh 2026.3.16
```
Stable publish:
- publishes `X.Y.Z` to npm under `latest`
- creates the local release commit
- creates the local tag `vX.Y.Z`
Stable publish refuses to proceed if:
- the current branch is not `release/X.Y.Z`
- the remote release branch does not exist yet
- the stable notes file is missing
- the target tag already exists locally or remotely
- the stable version already exists on npm
Those checks intentionally freeze the train after stable publish.
### 7. Push the stable branch commit and tag
After stable publish succeeds:
```bash
git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z
```
The GitHub Release notes come from:
- `releases/vX.Y.Z.md`
### 8. Merge the release branch back to `master`
Open a PR:
- base: `master`
- head: `release/X.Y.Z`
Merge rule:
- allowed: merge commit or fast-forward
- forbidden: squash merge
- forbidden: rebase merge
Post-merge verification:
```bash
git fetch public-gh --tags
git merge-base --is-ancestor "vX.Y.Z" "public-gh/master"
```
That command must succeed. If it fails, the published tagged commit is not reachable from `master`, which means the merge strategy was wrong.
### 9. Finish the external surfaces
After GitHub is correct:
- publish the changelog on the website
- write and send the announcement copy
- ensure public docs and install guidance point to the stable version
## GitHub Actions Release
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
Use it from the Actions tab on the relevant `release/X.Y.Z` branch:
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 the release branch, not 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 stable branch commit and tag, and create the GitHub Release
It does not merge the release branch back to `master` for you.
## Release Checklist
### Before any publish
- [ ] The release train exists on `release/X.Y.Z`
- [ ] The working tree is clean, including untracked files
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the train is cut
- [ ] The required verification gate passed on the exact branch head you want to publish
- [ ] The bump type is correct for the user-visible impact
- [ ] The stable changelog file exists or is ready at `releases/vX.Y.Z.md`
- [ ] You know which previous stable version you would roll back to if needed
### Before a stable
- [ ] The candidate has already passed smoke testing
- [ ] The remote `release/X.Y.Z` branch exists
- [ ] You are ready to push the stable branch commit and tag immediately after npm publish
- [ ] You are ready to create the GitHub Release immediately after the push
- [ ] You are ready to open the PR back to `master`
### 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`
- [ ] `vX.Y.Z` is reachable from `master`
- [ ] The website changelog is updated
- [ ] Announcement copy matches the stable release, not the canary
Then fix forward with a new stable release date.
## Failure Playbooks
### If the canary publishes but the smoke test fails
### If the canary publishes but smoke testing fails
Do not publish stable.
Do not run stable.
Instead:
1. fix the issue on `release/X.Y.Z`
2. publish another canary
3. rerun smoke testing
1. fix the issue on `master`
2. merge the fix
3. wait for the next automatic canary
4. rerun smoke testing
### If stable npm publish succeeds but push or GitHub release creation fails
### If stable npm publish succeeds but tag push or GitHub release creation fails
This is a partial release. npm is already live.
Do this immediately:
1. fix the git or GitHub issue from the same checkout
2. push the stable branch commit and tag
3. create the GitHub Release
1. push the missing tag
2. rerun `./scripts/create-github-release.sh YYYY.M.D`
3. verify the GitHub Release notes point at `releases/vYYYY.M.D.md`
Do not republish the same version.
### If `latest` is broken after stable publish
Preview:
Roll back the dist-tag:
```bash
./scripts/rollback-latest.sh X.Y.Z --dry-run
./scripts/rollback-latest.sh YYYY.M.D
```
Roll back:
Then fix forward with a new stable release.
```bash
./scripts/rollback-latest.sh X.Y.Z
```
## Related Files
This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
Then fix forward with a new patch release.
### If the GitHub Release notes are wrong
Re-run:
```bash
./scripts/create-github-release.sh X.Y.Z
```
If the release already exists, the script updates it.
## Related Docs
- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals
- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow
- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow
- [`scripts/release.sh`](../scripts/release.sh)
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh)
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh)
- [`doc/PUBLISHING.md`](PUBLISHING.md)
- [`doc/RELEASE-AUTOMATION-SETUP.md`](RELEASE-AUTOMATION-SETUP.md)

172
doc/memory-landscape.md Normal file
View File

@@ -0,0 +1,172 @@
# Memory Landscape
Date: 2026-03-17
This document summarizes the memory systems referenced in task `PAP-530` and extracts the design patterns that matter for Paperclip.
## What Paperclip Needs From This Survey
Paperclip is not trying to become a single opinionated memory engine. The more useful target is a control-plane memory surface that:
- stays company-scoped
- lets each company choose a default memory provider
- lets specific agents override that default
- keeps provenance back to Paperclip runs, issues, comments, and documents
- records memory-related cost and latency the same way the rest of the control plane records work
- works with plugin-provided providers, not only built-ins
The question is not "which memory project wins?" The question is "what is the smallest Paperclip contract that can sit above several very different memory systems without flattening away the useful differences?"
## Quick Grouping
### Hosted memory APIs
- `mem0`
- `supermemory`
- `Memori`
These optimize for a simple application integration story: send conversation/content plus an identity, then query for relevant memory or user context later.
### Agent-centric memory frameworks / memory OSes
- `MemOS`
- `memU`
- `EverMemOS`
- `OpenViking`
These treat memory as an agent runtime subsystem, not only as a search index. They usually add task memory, profiles, filesystem-style organization, async ingestion, or skill/resource management.
### Local-first memory stores / indexes
- `nuggets`
- `memsearch`
These emphasize local persistence, inspectability, and low operational overhead. They are useful because Paperclip is local-first today and needs at least one zero-config path.
## Per-Project Notes
| Project | Shape | Notable API / model | Strong fit for Paperclip | Main mismatch |
|---|---|---|---|---|
| [nuggets](https://github.com/NeoVertex1/nuggets) | local memory engine + messaging gateway | topic-scoped HRR memory with `remember`, `recall`, `forget`, fact promotion into `MEMORY.md` | good example of lightweight local memory and automatic promotion | very specific architecture; not a general multi-tenant service |
| [mem0](https://github.com/mem0ai/mem0) | hosted + OSS SDK | `add`, `search`, `getAll`, `get`, `update`, `delete`, `deleteAll`; entity partitioning via `user_id`, `agent_id`, `run_id`, `app_id` | closest to a clean provider API with identities and metadata filters | provider owns extraction heavily; Paperclip should not assume every backend behaves like mem0 |
| [MemOS](https://github.com/MemTensor/MemOS) | memory OS / framework | unified add-retrieve-edit-delete, memory cubes, multimodal memory, tool memory, async scheduler, feedback/correction | strong source for optional capabilities beyond plain search | much broader than the minimal contract Paperclip should standardize first |
| [supermemory](https://github.com/supermemoryai/supermemory) | hosted memory + context API | `add`, `profile`, `search.memories`, `search.documents`, document upload, settings; automatic profile building and forgetting | strong example of "context bundle" rather than raw search results | heavily productized around its own ontology and hosted flow |
| [memU](https://github.com/NevaMind-AI/memU) | proactive agent memory framework | file-system metaphor, proactive loop, intent prediction, always-on companion model | good source for when memory should trigger agent behavior, not just retrieval | proactive assistant framing is broader than Paperclip's task-centric control plane |
| [Memori](https://github.com/MemoriLabs/Memori) | hosted memory fabric + SDK wrappers | registers against LLM SDKs, attribution via `entity_id` + `process_id`, sessions, cloud + BYODB | strong example of automatic capture around model clients | wrapper-centric design does not map 1:1 to Paperclip's run / issue / comment lifecycle |
| [EverMemOS](https://github.com/EverMind-AI/EverMemOS) | conversational long-term memory system | MemCell extraction, structured narratives, user profiles, hybrid retrieval / reranking | useful model for provenance-rich structured memories and evolving profiles | focused on conversational memory rather than generalized control-plane events |
| [memsearch](https://github.com/zilliztech/memsearch) | markdown-first local memory index | markdown as source of truth, `index`, `search`, `watch`, transcript parsing, plugin hooks | excellent baseline for a local built-in provider and inspectable provenance | intentionally simple; no hosted service semantics or rich correction workflow |
| [OpenViking](https://github.com/volcengine/OpenViking) | context database | filesystem-style organization of memories/resources/skills, tiered loading, visualized retrieval trajectories | strong source for browse/inspect UX and context provenance | treats "context database" as a larger product surface than Paperclip should own |
## Common Primitives Across The Landscape
Even though the systems disagree on architecture, they converge on a few primitives:
- `ingest`: add memory from text, messages, documents, or transcripts
- `query`: search or retrieve memory given a task, question, or scope
- `scope`: partition memory by user, agent, project, process, or session
- `provenance`: carry enough metadata to explain where a memory came from
- `maintenance`: update, forget, dedupe, compact, or correct memories over time
- `context assembly`: turn raw memories into a prompt-ready bundle for the agent
If Paperclip does not expose these, it will not adapt well to the systems above.
## Where The Systems Differ
These differences are exactly why Paperclip needs a layered contract instead of a single hard-coded engine.
### 1. Who owns extraction?
- `mem0`, `supermemory`, and `Memori` expect the provider to infer memories from conversations.
- `memsearch` expects the host to decide what markdown to write, then indexes it.
- `MemOS`, `memU`, `EverMemOS`, and `OpenViking` sit somewhere in between and often expose richer memory construction pipelines.
Paperclip should support both:
- provider-managed extraction
- Paperclip-managed extraction with provider-managed storage / retrieval
### 2. What is the source of truth?
- `memsearch` and `nuggets` make the source inspectable on disk.
- hosted APIs often make the provider store canonical.
- filesystem-style systems like `OpenViking` and `memU` treat hierarchy itself as part of the memory model.
Paperclip should not require a single storage shape. It should require normalized references back to Paperclip entities.
### 3. Is memory just search, or also profile and planning state?
- `mem0` and `memsearch` center search and CRUD.
- `supermemory` adds user profiles as a first-class output.
- `MemOS`, `memU`, `EverMemOS`, and `OpenViking` expand into tool traces, task memory, resources, and skills.
Paperclip should make plain search the minimum contract and richer outputs optional capabilities.
### 4. Is memory synchronous or asynchronous?
- local tools often work synchronously in-process.
- larger systems add schedulers, background indexing, compaction, or sync jobs.
Paperclip needs both direct request/response operations and background maintenance hooks.
## Paperclip-Specific Takeaways
### Paperclip should own these concerns
- binding a provider to a company and optionally overriding it per agent
- mapping Paperclip entities into provider scopes
- provenance back to issue comments, documents, runs, and activity
- cost / token / latency reporting for memory work
- browse and inspect surfaces in the Paperclip UI
- governance on destructive operations
### Providers should own these concerns
- extraction heuristics
- embedding / indexing strategy
- ranking and reranking
- profile synthesis
- contradiction resolution and forgetting logic
- storage engine details
### The control-plane contract should stay small
Paperclip does not need to standardize every feature from every provider. It needs:
- a required portable core
- optional capability flags for richer providers
- a way to record provider-native ids and metadata without pretending all providers are equivalent internally
## Recommended Direction
Paperclip should adopt a two-layer memory model:
1. `Memory binding + control plane layer`
Paperclip decides which provider key is in effect for a company, agent, or project, and it logs every memory operation with provenance and usage.
2. `Provider adapter layer`
A built-in or plugin-supplied adapter turns Paperclip memory requests into provider-specific calls.
The portable core should cover:
- ingest / write
- search / recall
- browse / inspect
- get by provider record handle
- forget / correction
- usage reporting
Optional capabilities can cover:
- profile synthesis
- async ingestion
- multimodal content
- tool / resource / skill memory
- provider-native graph browsing
That is enough to support:
- a local markdown-first baseline similar to `memsearch`
- hosted services similar to `mem0`, `supermemory`, or `Memori`
- richer agent-memory systems like `MemOS` or `OpenViking`
without forcing Paperclip itself to become a monolithic memory engine.

View File

@@ -0,0 +1,426 @@
# Paperclip Memory Service Plan
## Goal
Define a Paperclip memory service and surface API that can sit above multiple memory backends, while preserving Paperclip's control-plane requirements:
- company scoping
- auditability
- provenance back to Paperclip work objects
- budget / cost visibility
- plugin-first extensibility
This plan is based on the external landscape summarized in `doc/memory-landscape.md` and on the current Paperclip architecture in:
- `doc/SPEC-implementation.md`
- `doc/plugins/PLUGIN_SPEC.md`
- `doc/plugins/PLUGIN_AUTHORING_GUIDE.md`
- `packages/plugins/sdk/src/types.ts`
## Recommendation In One Sentence
Paperclip should not embed one opinionated memory engine into core. It should add a company-scoped memory control plane with a small normalized adapter contract, then let built-ins and plugins implement the provider-specific behavior.
## Product Decisions
### 1. Memory is company-scoped by default
Every memory binding belongs to exactly one company.
That binding can then be:
- the company default
- an agent override
- a project override later if we need it
No cross-company memory sharing in the initial design.
### 2. Providers are selected by key
Each configured memory provider gets a stable key inside a company, for example:
- `default`
- `mem0-prod`
- `local-markdown`
- `research-kb`
Agents and services resolve the active provider by key, not by hard-coded vendor logic.
### 3. Plugins are the primary provider path
Built-ins are useful for a zero-config local path, but most providers should arrive through the existing Paperclip plugin runtime.
That keeps the core small and matches the current direction that optional knowledge-like systems live at the edges.
### 4. Paperclip owns routing, provenance, and accounting
Providers should not decide how Paperclip entities map to governance.
Paperclip core should own:
- who is allowed to call a memory operation
- which company / agent / project scope is active
- what issue / run / comment / document the operation belongs to
- how usage gets recorded
### 5. Automatic memory should be narrow at first
Automatic capture is useful, but broad silent capture is dangerous.
Initial automatic hooks should be:
- post-run capture from agent runs
- issue comment / document capture when the binding enables it
- pre-run recall for agent context hydration
Everything else should start explicit.
## Proposed Concepts
### Memory provider
A built-in or plugin-supplied implementation that stores and retrieves memory.
Examples:
- local markdown + vector index
- mem0 adapter
- supermemory adapter
- MemOS adapter
### Memory binding
A company-scoped configuration record that points to a provider and carries provider-specific config.
This is the object selected by key.
### Memory scope
The normalized Paperclip scope passed into a provider request.
At minimum:
- `companyId`
- optional `agentId`
- optional `projectId`
- optional `issueId`
- optional `runId`
- optional `subjectId` for external/user identity
### Memory source reference
The provenance handle that explains where a memory came from.
Supported source kinds should include:
- `issue_comment`
- `issue_document`
- `issue`
- `run`
- `activity`
- `manual_note`
- `external_document`
### Memory operation
A normalized write, query, browse, or delete action performed through Paperclip.
Paperclip should log every operation, whether the provider is local or external.
## Required Adapter Contract
The required core should be small enough to fit `memsearch`, `mem0`, `Memori`, `MemOS`, or `OpenViking`.
```ts
export interface MemoryAdapterCapabilities {
profile?: boolean;
browse?: boolean;
correction?: boolean;
asyncIngestion?: boolean;
multimodal?: boolean;
providerManagedExtraction?: boolean;
}
export interface MemoryScope {
companyId: string;
agentId?: string;
projectId?: string;
issueId?: string;
runId?: string;
subjectId?: string;
}
export interface MemorySourceRef {
kind:
| "issue_comment"
| "issue_document"
| "issue"
| "run"
| "activity"
| "manual_note"
| "external_document";
companyId: string;
issueId?: string;
commentId?: string;
documentKey?: string;
runId?: string;
activityId?: string;
externalRef?: string;
}
export interface MemoryUsage {
provider: string;
model?: string;
inputTokens?: number;
outputTokens?: number;
embeddingTokens?: number;
costCents?: number;
latencyMs?: number;
details?: Record<string, unknown>;
}
export interface MemoryWriteRequest {
bindingKey: string;
scope: MemoryScope;
source: MemorySourceRef;
content: string;
metadata?: Record<string, unknown>;
mode?: "append" | "upsert" | "summarize";
}
export interface MemoryRecordHandle {
providerKey: string;
providerRecordId: string;
}
export interface MemoryQueryRequest {
bindingKey: string;
scope: MemoryScope;
query: string;
topK?: number;
intent?: "agent_preamble" | "answer" | "browse";
metadataFilter?: Record<string, unknown>;
}
export interface MemorySnippet {
handle: MemoryRecordHandle;
text: string;
score?: number;
summary?: string;
source?: MemorySourceRef;
metadata?: Record<string, unknown>;
}
export interface MemoryContextBundle {
snippets: MemorySnippet[];
profileSummary?: string;
usage?: MemoryUsage[];
}
export interface MemoryAdapter {
key: string;
capabilities: MemoryAdapterCapabilities;
write(req: MemoryWriteRequest): Promise<{
records?: MemoryRecordHandle[];
usage?: MemoryUsage[];
}>;
query(req: MemoryQueryRequest): Promise<MemoryContextBundle>;
get(handle: MemoryRecordHandle, scope: MemoryScope): Promise<MemorySnippet | null>;
forget(handles: MemoryRecordHandle[], scope: MemoryScope): Promise<{ usage?: MemoryUsage[] }>;
}
```
This contract intentionally does not force a provider to expose its internal graph, filesystem, or ontology.
## Optional Adapter Surfaces
These should be capability-gated, not required:
- `browse(scope, filters)` for file-system / graph / timeline inspection
- `correct(handle, patch)` for natural-language correction flows
- `profile(scope)` when the provider can synthesize stable preferences or summaries
- `sync(source)` for connectors or background ingestion
- `explain(queryResult)` for providers that can expose retrieval traces
## What Paperclip Should Persist
Paperclip should not mirror the full provider memory corpus into Postgres unless the provider is a Paperclip-managed local provider.
Paperclip core should persist:
- memory bindings and overrides
- provider keys and capability metadata
- normalized memory operation logs
- provider record handles returned by operations when available
- source references back to issue comments, documents, runs, and activity
- usage and cost data
For external providers, the memory payload itself can remain in the provider.
## Hook Model
### Automatic hooks
These should be low-risk and easy to reason about:
1. `pre-run hydrate`
Before an agent run starts, Paperclip may call `query(... intent = "agent_preamble")` using the active binding.
2. `post-run capture`
After a run finishes, Paperclip may write a summary or transcript-derived note tied to the run.
3. `issue comment / document capture`
When enabled on the binding, Paperclip may capture selected issue comments or issue documents as memory sources.
### Explicit hooks
These should be tool- or UI-driven first:
- `memory.search`
- `memory.note`
- `memory.forget`
- `memory.correct`
- `memory.browse`
### Not automatic in the first version
- broad web crawling
- silent import of arbitrary repo files
- cross-company memory sharing
- automatic destructive deletion
- provider migration between bindings
## Agent UX Rules
Paperclip should give agents both automatic recall and explicit tools, with simple guidance:
- use `memory.search` when the task depends on prior decisions, people, projects, or long-running context that is not in the current issue thread
- use `memory.note` when a durable fact, preference, or decision should survive this run
- use `memory.correct` when the user explicitly says prior context is wrong
- rely on post-run auto-capture for ordinary session residue so agents do not have to write memory notes for every trivial exchange
This keeps memory available without forcing every agent prompt to become a memory-management protocol.
## Browse And Inspect Surface
Paperclip needs a first-class UI for memory, otherwise providers become black boxes.
The initial browse surface should support:
- active binding by company and agent
- recent memory operations
- recent write sources
- query results with source backlinks
- filters by agent, issue, run, source kind, and date
- provider usage / cost / latency summaries
When a provider supports richer browsing, the plugin can add deeper views through the existing plugin UI surfaces.
## Cost And Evaluation
Every adapter response should be able to return usage records.
Paperclip should roll up:
- memory inference tokens
- embedding tokens
- external provider cost
- latency
- query count
- write count
It should also record evaluation-oriented metrics where possible:
- recall hit rate
- empty query rate
- manual correction count
- per-binding success / failure counts
This is important because a memory system that "works" but silently burns budget is not acceptable in Paperclip.
## Suggested Data Model Additions
At the control-plane level, the likely new core tables are:
- `memory_bindings`
- company-scoped key
- provider id / plugin id
- config blob
- enabled status
- `memory_binding_targets`
- target type (`company`, `agent`, later `project`)
- target id
- binding id
- `memory_operations`
- company id
- binding id
- operation type (`write`, `query`, `forget`, `browse`, `correct`)
- scope fields
- source refs
- usage / latency / cost
- success / error
Provider-specific long-form state should stay in plugin state or the provider itself unless a built-in local provider needs its own schema.
## Recommended First Built-In
The best zero-config built-in is a local markdown-first provider with optional semantic indexing.
Why:
- it matches Paperclip's local-first posture
- it is inspectable
- it is easy to back up and debug
- it gives the system a baseline even without external API keys
The design should still treat that built-in as just another provider behind the same control-plane contract.
## Rollout Phases
### Phase 1: Control-plane contract
- add memory binding models and API types
- add plugin capability / registration surface for memory providers
- add operation logging and usage reporting
### Phase 2: One built-in + one plugin example
- ship a local markdown-first provider
- ship one hosted adapter example to validate the external-provider path
### Phase 3: UI inspection
- add company / agent memory settings
- add a memory operation explorer
- add source backlinks to issues and runs
### Phase 4: Automatic hooks
- pre-run hydrate
- post-run capture
- selected issue comment / document capture
### Phase 5: Rich capabilities
- correction flows
- provider-native browse / graph views
- project-level overrides if needed
- evaluation dashboards
## Open Questions
- Should project overrides exist in V1 of the memory service, or should we force company default + agent override first?
- Do we want Paperclip-managed extraction pipelines at all, or should built-ins be the only place where Paperclip owns extraction?
- Should memory usage extend the current `cost_events` model directly, or should memory operations keep a parallel usage log and roll up into `cost_events` secondarily?
- Do we want provider install / binding changes to require approvals for some companies?
## Bottom Line
The right abstraction is:
- Paperclip owns memory bindings, scopes, provenance, governance, and usage reporting.
- Providers own extraction, ranking, storage, and provider-native memory semantics.
That gives Paperclip a stable "memory service" without locking the product to one memory philosophy or one vendor.

View File

@@ -0,0 +1,491 @@
# Release Automation and Versioning Simplification Plan
## Context
Paperclip's current release flow is documented in `doc/RELEASING.md` and implemented through:
- `.github/workflows/release.yml`
- `scripts/release-lib.sh`
- `scripts/release-start.sh`
- `scripts/release-preflight.sh`
- `scripts/release.sh`
- `scripts/create-github-release.sh`
Today the model is:
1. pick `patch`, `minor`, or `major`
2. create `release/X.Y.Z`
3. draft `releases/vX.Y.Z.md`
4. publish one or more canaries from that release branch
5. publish stable from that same branch
6. push tag + create GitHub Release
7. merge the release branch back to `master`
That is workable, but it creates friction in exactly the places that should be cheap:
- deciding `patch` vs `minor` vs `major`
- cutting and carrying release branches
- manually publishing canaries
- thinking about changelog generation for canaries
- handling npm credentials safely in a public repo
The target state from this discussion is simpler:
- every push to `master` publishes a canary automatically
- stable releases are promoted deliberately from a vetted commit
- versioning is date-driven instead of semantics-driven
- stable publishing is secure even in a public open-source repository
- changelog generation happens only for real stable releases
## Recommendation In One Sentence
Move Paperclip to semver-compatible calendar versioning, auto-publish canaries from `master`, promote stable from a chosen tested commit, and use npm trusted publishing plus GitHub environments so no long-lived npm or LLM token needs to live in Actions.
## Core Decisions
### 1. Use calendar versions, but keep semver syntax
The repo and npm tooling still assume semver-shaped version strings in many places. That does not mean Paperclip must keep semver as a product policy. It does mean the version format should remain semver-valid.
Recommended format:
- stable: `YYYY.M.D`
- canary: `YYYY.M.D-canary.N`
Examples:
- stable on March 17, 2026: `2026.3.17`
- third canary on March 17, 2026: `2026.3.17-canary.2`
Why this shape:
- it removes `patch/minor/major` decisions
- it is valid semver syntax
- it stays compatible with npm, dist-tags, and existing semver validators
- it is close to the format you actually want
Important constraints:
- `2026.03.17` is not the format to use
- numeric semver identifiers do not allow leading zeroes
- `2026.03.16.8` is not the format to use
- semver has three numeric components, not four
- the practical semver-safe equivalent of your example is `2026.3.16-canary.8`
This is effectively CalVer on semver rails.
### 2. Accept that CalVer changes the compatibility contract
This is not semver in spirit anymore. It is semver in syntax only.
That tradeoff is probably acceptable for Paperclip, but it should be explicit:
- consumers no longer infer compatibility from `major/minor/patch`
- release notes become the compatibility signal
- downstream users should prefer exact pins or deliberate upgrades
This is especially relevant for public library packages like `@paperclipai/shared`, `@paperclipai/db`, and the adapter packages.
### 3. Drop release branches for normal publishing
If every merge to `master` publishes a canary, the current `release/X.Y.Z` train model becomes more ceremony than value.
Recommended replacement:
- `master` is the only canary train
- every push to `master` can publish a canary
- stable is published from a chosen commit or canary tag on `master`
This matches the workflow you actually want:
- merge continuously
- let npm always have a fresh canary
- choose a known-good canary later and promote that commit to stable
### 4. Promote by source ref, not by "renaming" a canary
This is the most important mechanical constraint.
npm can move dist-tags, but it does not let you rename an already-published version. That means:
- you can move `latest` to `paperclipai@1.2.3`
- you cannot turn `paperclipai@2026.3.16-canary.8` into `paperclipai@2026.3.17`
So "promote canary to stable" really means:
1. choose the commit or canary tag you trust
2. rebuild from that exact commit
3. publish it again with the stable version string
Because of that, the stable workflow should take a source ref, not just a bump type.
Recommended stable input:
- `source_ref`
- commit SHA, or
- a canary git tag such as `canary/v2026.3.16-canary.8`
### 5. Only stable releases get release notes, tags, and GitHub Releases
Canaries should stay lightweight:
- publish to npm under `canary`
- optionally create a lightweight or annotated git tag
- do not create GitHub Releases
- do not require `releases/v*.md`
- do not spend LLM tokens
Stable releases should remain the public narrative surface:
- git tag `v2026.3.17`
- GitHub Release `v2026.3.17`
- stable changelog file `releases/v2026.3.17.md`
## Security Model
### Recommendation
Use npm trusted publishing with GitHub Actions OIDC, then disable token-based publishing access for the packages.
Why:
- no long-lived `NPM_TOKEN` in repo or org secrets
- no personal npm token in Actions
- short-lived credentials minted only for the authorized workflow
- automatic npm provenance for public packages in public repos
This is the cleanest answer to the open-repo security concern.
### Concrete controls
#### 1. Use one release workflow file
Use one workflow filename for both canary and stable publishing:
- `.github/workflows/release.yml`
Why:
- npm trusted publishing is configured per workflow filename
- npm currently allows one trusted publisher configuration per package
- GitHub environments can still provide separate canary/stable approval rules inside the same workflow
#### 2. Use separate GitHub environments
Recommended environments:
- `npm-canary`
- `npm-stable`
Recommended policy:
- `npm-canary`
- allowed branch: `master`
- no human reviewer required
- `npm-stable`
- allowed branch: `master`
- required reviewer enabled
- prevent self-review enabled
- admin bypass disabled
Stable should require an explicit second human gate even if the workflow is manually dispatched.
#### 3. Lock down workflow edits
Add or tighten `CODEOWNERS` coverage for:
- `.github/workflows/*`
- `scripts/release*`
- `doc/RELEASING.md`
This matters because trusted publishing authorizes a workflow file. The biggest remaining risk is not secret exfiltration from forks. It is a maintainer-approved change to the release workflow itself.
#### 4. Remove traditional npm token access after OIDC works
After trusted publishing is verified:
- set package publishing access to require 2FA and disallow tokens
- revoke any legacy automation tokens
That eliminates the "someone stole the npm token" class of failure.
### What not to do
- do not put your personal Claude or npm token in GitHub Actions
- do not run release logic from `pull_request_target`
- do not make stable publishing depend on a repo secret if OIDC can handle it
- do not create canary GitHub Releases
## Changelog Strategy
### Recommendation
Generate stable changelogs only, and keep LLM-assisted changelog generation out of CI for now.
Reasoning:
- canaries happen too often
- canaries do not need polished public notes
- putting a personal Claude token into Actions is not worth the risk
- stable release cadence is low enough that a human-in-the-loop step is acceptable
Recommended stable path:
1. pick a canary commit or tag
2. run changelog generation locally from a trusted machine
3. commit `releases/vYYYY.M.D.md`
4. run stable promotion
If the notes are not ready yet, a fallback is acceptable:
- publish stable
- create a minimal GitHub Release
- update `releases/vYYYY.M.D.md` immediately afterward
But the better steady-state is to have the stable notes committed before stable publish.
### Future option
If you later want CI-assisted changelog drafting, do it with:
- a dedicated service account
- a token scoped only for changelog generation
- a manual workflow
- a dedicated environment with required reviewers
That is phase-two hardening work, not a phase-one requirement.
## Proposed Future Workflow
### Canary workflow
Trigger:
- `push` on `master`
Steps:
1. checkout the merged `master` commit
2. run verification on that exact commit
3. compute canary version for current UTC date
4. version public packages to `YYYY.M.D-canary.N`
5. publish to npm with dist-tag `canary`
6. create a canary git tag for traceability
Recommended canary tag format:
- `canary/v2026.3.17-canary.4`
Outputs:
- npm canary published
- git tag created
- no GitHub Release
- no changelog file required
### Stable workflow
Trigger:
- `workflow_dispatch`
Inputs:
- `source_ref`
- optional `stable_date`
- `dry_run`
Steps:
1. checkout `source_ref`
2. run verification on that exact commit
3. compute stable version from UTC date or provided override
4. fail if `vYYYY.M.D` already exists
5. require `releases/vYYYY.M.D.md`
6. version public packages to `YYYY.M.D`
7. publish to npm under `latest`
8. create git tag `vYYYY.M.D`
9. push tag
10. create GitHub Release from `releases/vYYYY.M.D.md`
Outputs:
- stable npm release
- stable git tag
- GitHub Release
- clean public changelog surface
## Implementation Guidance
### 1. Replace bump-type version math with explicit version computation
The current release scripts depend on:
- `patch`
- `minor`
- `major`
That logic should be replaced with:
- `compute_canary_version_for_date`
- `compute_stable_version_for_date`
For example:
- `stable_version_for_utc_date(2026-03-17) -> 2026.3.17`
- `next_canary_for_utc_date(2026-03-17) -> 2026.3.17-canary.0`
### 2. Stop requiring `release/X.Y.Z`
These current invariants should be removed from the happy path:
- "must run from branch `release/X.Y.Z`"
- "stable and canary for `X.Y.Z` come from the same release branch"
- `release-start.sh`
Replace them with:
- canary must run from `master`
- stable may run from a pinned `source_ref`
### 3. Keep Changesets only if it stays helpful
The current system uses Changesets to:
- rewrite package versions
- maintain package-level `CHANGELOG.md` files
- publish packages
With CalVer, Changesets may still be useful for publish orchestration, but it should no longer own version selection.
Recommended implementation order:
1. keep `changeset publish` if it works with explicitly-set versions
2. replace version computation with a small explicit versioning script
3. if Changesets keeps fighting the model, remove it from release publishing entirely
Paperclip's release problem is now "publish the whole fixed package set at one explicit version", not "derive the next semantic bump from human intent".
### 4. Add a dedicated versioning script
Recommended new script:
- `scripts/set-release-version.mjs`
Responsibilities:
- set the version in all public publishable packages
- update any internal exact-version references needed for publishing
- update CLI version strings
- avoid broad string replacement across unrelated files
This is safer than keeping a bump-oriented changeset flow and then forcing it into a date-based scheme.
### 5. Keep rollback based on dist-tags
`rollback-latest.sh` should stay, but it should stop assuming a semver meaning beyond syntax.
It should continue to:
- repoint `latest` to a prior stable version
- never unpublish
## Tradeoffs and Risks
### 1. One stable per UTC day
With plain `YYYY.M.D`, you get one stable release per UTC day.
That is probably fine, but it is a real product rule.
If you need multiple same-day stables later, you have three options:
1. accept a less pretty stable format
2. go back to a serial patch component
3. keep daily stable cadence and use canaries for same-day fixes
My recommendation is to accept one stable per UTC day unless reality proves otherwise.
### 2. Public package consumers lose semver intent signaling
This is the main downside of CalVer.
If that becomes a problem, one alternative is:
- use CalVer for the CLI package only
- keep semver for library packages
That is more complex operationally, so I would not start there unless package consumers actually need it.
### 3. Auto-canary means more publish traffic
Publishing on every `master` merge means:
- more npm versions
- more git tags
- more registry noise
That is acceptable if canaries stay clearly separate:
- npm dist-tag `canary`
- no GitHub Release
- no external announcement
## Rollout Plan
### Phase 1: Security foundation
1. Create `release.yml`
2. Configure npm trusted publishers for all public packages
3. Create `npm-canary` and `npm-stable` environments
4. Add `CODEOWNERS` protection for release files
5. Verify OIDC publishing works
6. Disable token-based publishing access and revoke old tokens
### Phase 2: Canary automation
1. Add canary workflow on `push` to `master`
2. Add explicit calendar-version computation
3. Add canary git tagging
4. Remove changelog requirement from canaries
5. Update `doc/RELEASING.md`
### Phase 3: Stable promotion
1. Add manual stable workflow with `source_ref`
2. Require stable notes file
3. Publish stable + tag + GitHub Release
4. Update rollback docs and scripts
5. Retire release-branch assumptions
### Phase 4: Cleanup
1. Remove `release-start.sh` from the primary path
2. Remove `patch/minor/major` from maintainer docs
3. Decide whether to keep or remove Changesets from publishing
4. Document the CalVer compatibility contract publicly
## Concrete Recommendation
Paperclip should adopt this model:
- stable versions: `YYYY.M.D`
- canary versions: `YYYY.M.D-canary.N`
- canaries auto-published on every push to `master`
- stables manually promoted from a chosen tested commit or canary tag
- no release branches in the default path
- no canary changelog files
- no canary GitHub Releases
- no Claude token in GitHub Actions
- no npm automation token in GitHub Actions
- npm trusted publishing plus GitHub environments for release security
That gets rid of the annoying part of semver without fighting npm, makes canaries cheap, keeps stables deliberate, and materially improves the security posture of the public repository.
## External References
- npm trusted publishing: https://docs.npmjs.com/trusted-publishers/
- npm dist-tags: https://docs.npmjs.com/adding-dist-tags-to-packages/
- npm semantic versioning guidance: https://docs.npmjs.com/about-semantic-versioning/
- GitHub environments and deployment protection rules: https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments
- GitHub secrets behavior for forks: https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets

View File

@@ -18,13 +18,11 @@
"db:backup": "./scripts/backup-db.sh",
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
"build:npm": "./scripts/build-npm.sh",
"release:start": "./scripts/release-start.sh",
"release": "./scripts/release.sh",
"release:preflight": "./scripts/release-preflight.sh",
"release:canary": "./scripts/release.sh canary",
"release:stable": "./scripts/release.sh stable",
"release:github": "./scripts/create-github-release.sh",
"release:rollback": "./scripts/rollback-latest.sh",
"changeset": "changeset",
"version-packages": "changeset version",
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
"docs:dev": "cd docs && npx mintlify dev",
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
@@ -34,7 +32,6 @@
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed"
},
"devDependencies": {
"@changesets/cli": "^2.30.0",
"cross-env": "^10.1.0",
"@playwright/test": "^1.58.2",
"esbuild": "^0.27.3",

View File

@@ -14,11 +14,11 @@ 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
./scripts/create-github-release.sh 2026.3.17
./scripts/create-github-release.sh 2026.3.17 --dry-run
Notes:
- Run this after pushing the stable release branch and tag.
- Run this after pushing the stable tag.
- Defaults to git remote public-gh.
- If the release already exists, this script updates its title and notes.
EOF
@@ -48,7 +48,7 @@ if [ -z "$version" ]; then
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be a stable semver like 1.2.3." >&2
echo "Error: version must be a stable calendar version like 2026.3.17." >&2
exit 1
fi

View File

@@ -37,7 +37,7 @@ const workspacePaths = [
];
// Workspace packages that are NOT bundled and must stay as npm dependencies.
// These get published separately via Changesets and resolved at runtime.
// These get published separately and resolved at runtime.
const externalWorkspacePackages = new Set([
"@paperclipai/server",
]);
@@ -57,7 +57,7 @@ for (const pkgPath of workspacePaths) {
if (externalWorkspacePackages.has(name)) {
const pkgDirMap = { "@paperclipai/server": "server" };
const wsPkg = readPkg(pkgDirMap[name]);
allDeps[name] = `^${wsPkg.version}`;
allDeps[name] = wsPkg.version;
continue;
}
// Keep the more specific (pinned) version if conflict

View File

@@ -64,6 +64,11 @@ resolve_release_remote() {
return
fi
if git_remote_exists public; then
printf 'public\n'
return
fi
if git_remote_exists origin; then
printf 'origin\n'
return
@@ -76,6 +81,18 @@ fetch_release_remote() {
git -C "$REPO_ROOT" fetch "$1" --prune --tags
}
git_current_branch() {
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
}
git_local_tag_exists() {
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
}
git_remote_tag_exists() {
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
}
get_last_stable_tag() {
git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1
}
@@ -90,32 +107,27 @@ get_current_stable_version() {
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+)$/);
stable_version_for_date() {
node - "${1:-}" <<'NODE'
const input = process.argv[2];
if (!match) {
throw new Error(`invalid semver version: ${current}`);
const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
if (Number.isNaN(date.getTime())) {
console.error(`invalid date: ${input}`);
process.exit(1);
}
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(`${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}`);
NODE
}
process.stdout.write(`${major}.${minor}.${patch}`);
utc_date_iso() {
node <<'NODE'
const date = new Date();
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
process.stdout.write(`${y}-${m}-${d}`);
NODE
}
@@ -150,50 +162,16 @@ process.stdout.write(`${stable}-canary.${max + 1}`);
NODE
}
release_branch_name() {
printf 'release/%s\n' "$1"
}
release_notes_file() {
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
}
default_release_worktree_path() {
local version="$1"
local parent_dir
local repo_name
parent_dir="$(cd "$REPO_ROOT/.." && pwd)"
repo_name="$(basename "$REPO_ROOT")"
printf '%s/%s-release-%s\n' "$parent_dir" "$repo_name" "$version"
stable_tag_name() {
printf 'v%s\n' "$1"
}
git_current_branch() {
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
}
git_local_branch_exists() {
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$1"
}
git_remote_branch_exists() {
git -C "$REPO_ROOT" ls-remote --exit-code --heads "$2" "refs/heads/$1" >/dev/null 2>&1
}
git_local_tag_exists() {
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
}
git_remote_tag_exists() {
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
}
npm_version_exists() {
local version="$1"
local resolved
resolved="$(npm view "paperclipai@${version}" version 2>/dev/null || true)"
[ "$resolved" = "$version" ]
canary_tag_name() {
printf 'canary/v%s\n' "$1"
}
npm_package_version_exists() {
@@ -232,50 +210,38 @@ require_clean_worktree() {
fi
}
git_worktree_path_for_branch() {
local branch_ref="refs/heads/$1"
git -C "$REPO_ROOT" worktree list --porcelain | awk -v branch_ref="$branch_ref" '
$1 == "worktree" { path = substr($0, 10) }
$1 == "branch" && $2 == branch_ref { print path; exit }
'
}
path_is_worktree_for_branch() {
local path="$1"
local branch="$2"
require_on_master_branch() {
local current_branch
[ -d "$path" ] || return 1
current_branch="$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ "$current_branch" = "$branch" ]
}
ensure_release_branch_for_version() {
local stable_version="$1"
local current_branch
local expected_branch
current_branch="$(git_current_branch)"
expected_branch="$(release_branch_name "$stable_version")"
if [ -z "$current_branch" ]; then
release_fail "release work must run from branch $expected_branch, but HEAD is detached."
fi
if [ "$current_branch" != "$expected_branch" ]; then
release_fail "release work must run from branch $expected_branch, but current branch is $current_branch."
if [ "$current_branch" != "master" ]; then
release_fail "this release step must run from branch master, but current branch is ${current_branch:-<detached>}."
fi
}
stable_release_exists_anywhere() {
local stable_version="$1"
local remote="$2"
local tag="v$stable_version"
require_npm_publish_auth() {
local dry_run="$1"
git_local_tag_exists "$tag" || git_remote_tag_exists "$tag" "$remote" || npm_version_exists "$stable_version"
if [ "$dry_run" = true ]; then
return
fi
if npm whoami >/dev/null 2>&1; then
release_info " ✓ Logged in to npm as $(npm whoami)"
return
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return
fi
release_fail "npm publish auth is not available. Use 'npm login' locally or run from GitHub Actions with trusted publishing."
}
release_train_is_frozen() {
stable_release_exists_anywhere "$1" "$2"
list_public_package_info() {
node "$REPO_ROOT/scripts/release-package-map.mjs" list
}
set_public_package_version() {
node "$REPO_ROOT/scripts/release-package-map.mjs" set-version "$1"
}

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env node
import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const roots = ["packages", "server", "ui", "cli"];
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
function discoverPublicPackages() {
const packages = [];
function walk(relDir) {
const absDir = join(repoRoot, relDir);
if (!existsSync(absDir)) return;
const pkgPath = join(absDir, "package.json");
if (existsSync(pkgPath)) {
const pkg = readJson(pkgPath);
if (!pkg.private) {
packages.push({
dir: relDir,
pkgPath,
name: pkg.name,
version: pkg.version,
pkg,
});
}
return;
}
for (const entry of readdirSync(absDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git") continue;
walk(join(relDir, entry.name));
}
}
for (const rel of roots) {
walk(rel);
}
return packages;
}
function sortTopologically(packages) {
const byName = new Map(packages.map((pkg) => [pkg.name, pkg]));
const visited = new Set();
const visiting = new Set();
const ordered = [];
function visit(pkg) {
if (visited.has(pkg.name)) return;
if (visiting.has(pkg.name)) {
throw new Error(`cycle detected in public package graph at ${pkg.name}`);
}
visiting.add(pkg.name);
const dependencySections = [
pkg.pkg.dependencies ?? {},
pkg.pkg.optionalDependencies ?? {},
pkg.pkg.peerDependencies ?? {},
];
for (const deps of dependencySections) {
for (const depName of Object.keys(deps)) {
const dep = byName.get(depName);
if (dep) visit(dep);
}
}
visiting.delete(pkg.name);
visited.add(pkg.name);
ordered.push(pkg);
}
for (const pkg of [...packages].sort((a, b) => a.dir.localeCompare(b.dir))) {
visit(pkg);
}
return ordered;
}
function replaceWorkspaceDeps(deps, version) {
if (!deps) return deps;
const next = { ...deps };
for (const [name, value] of Object.entries(next)) {
if (!name.startsWith("@paperclipai/")) continue;
if (typeof value !== "string" || !value.startsWith("workspace:")) continue;
next[name] = version;
}
return next;
}
function setVersion(version) {
const packages = sortTopologically(discoverPublicPackages());
for (const pkg of packages) {
const nextPkg = {
...pkg.pkg,
version,
dependencies: replaceWorkspaceDeps(pkg.pkg.dependencies, version),
optionalDependencies: replaceWorkspaceDeps(pkg.pkg.optionalDependencies, version),
peerDependencies: replaceWorkspaceDeps(pkg.pkg.peerDependencies, version),
devDependencies: replaceWorkspaceDeps(pkg.pkg.devDependencies, version),
};
writeFileSync(pkg.pkgPath, `${JSON.stringify(nextPkg, null, 2)}\n`);
}
const cliEntryPath = join(repoRoot, "cli/src/index.ts");
const cliEntry = readFileSync(cliEntryPath, "utf8");
const nextCliEntry = cliEntry.replace(
/\.version\("([^"]+)"\)/,
`.version("${version}")`,
);
if (cliEntry === nextCliEntry) {
throw new Error("failed to rewrite CLI version string in cli/src/index.ts");
}
writeFileSync(cliEntryPath, nextCliEntry);
}
function listPackages() {
const packages = sortTopologically(discoverPublicPackages());
for (const pkg of packages) {
process.stdout.write(`${pkg.dir}\t${pkg.name}\t${pkg.version}\n`);
}
}
function usage() {
process.stderr.write(
[
"Usage:",
" node scripts/release-package-map.mjs list",
" node scripts/release-package-map.mjs set-version <version>",
"",
].join("\n"),
);
}
const [command, arg] = process.argv.slice(2);
if (command === "list") {
listPackages();
process.exit(0);
}
if (command === "set-version") {
if (!arg) {
usage();
process.exit(1);
}
setVersion(arg);
process.exit(0);
}
usage();
process.exit(1);

View File

@@ -1,201 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
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
- verifies you are on the matching release/X.Y.Z branch
- shows the last stable tag and the target version(s)
- shows the git/npm/GitHub release-train state
- 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
RELEASE_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$RELEASE_REMOTE"
LAST_STABLE_TAG="$(get_last_stable_tag)"
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
CURRENT_BRANCH="$(git_current_branch)"
RELEASE_TAG="v$TARGET_STABLE_VERSION"
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
require_clean_worktree
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
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
REMOTE_BRANCH_EXISTS="no"
REMOTE_TAG_EXISTS="no"
LOCAL_TAG_EXISTS="no"
NPM_STABLE_EXISTS="no"
if git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$RELEASE_REMOTE"; then
REMOTE_BRANCH_EXISTS="yes"
fi
if git_local_tag_exists "$RELEASE_TAG"; then
LOCAL_TAG_EXISTS="yes"
fi
if git_remote_tag_exists "$RELEASE_TAG" "$RELEASE_REMOTE"; then
REMOTE_TAG_EXISTS="yes"
fi
if npm_version_exists "$TARGET_STABLE_VERSION"; then
NPM_STABLE_EXISTS="yes"
fi
if [ "$LOCAL_TAG_EXISTS" = "yes" ] || [ "$REMOTE_TAG_EXISTS" = "yes" ] || [ "$NPM_STABLE_EXISTS" = "yes" ]; then
echo "Error: release train $EXPECTED_RELEASE_BRANCH is frozen because $RELEASE_TAG already exists locally, remotely, or version $TARGET_STABLE_VERSION is already on npm." >&2
exit 1
fi
echo ""
echo "==> Release preflight"
echo " Remote: $RELEASE_REMOTE"
echo " Channel: $channel"
echo " Bump: $bump_type"
echo " Current branch: ${CURRENT_BRANCH:-<detached>}"
echo " Expected branch: $EXPECTED_RELEASE_BRANCH"
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 " ✓ Branch matches release train"
echo ""
echo "==> Release train state"
echo " Remote branch exists: $REMOTE_BRANCH_EXISTS"
echo " Local stable tag exists: $LOCAL_TAG_EXISTS"
echo " Remote stable tag exists: $REMOTE_TAG_EXISTS"
echo " Stable version on npm: $NPM_STABLE_EXISTS"
if [ -f "$NOTES_FILE" ]; then
echo " Release notes: present at $NOTES_FILE"
else
echo " Release notes: missing at $NOTES_FILE"
fi
if [ "$REMOTE_BRANCH_EXISTS" = "no" ]; then
echo " Warning: remote branch $EXPECTED_RELEASE_BRANCH does not exist on $RELEASE_REMOTE yet."
fi
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 " Remote: $RELEASE_REMOTE"
echo " Channel: $channel"
echo " Bump: $bump_type"
echo " Release branch: $EXPECTED_RELEASE_BRANCH"
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
echo " Next canary version: $TARGET_CANARY_VERSION"
echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N"
fi
echo ""
echo "Preflight passed for $channel release."

View File

@@ -1,182 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
dry_run=false
push_branch=true
bump_type=""
worktree_path=""
usage() {
cat <<'EOF'
Usage:
./scripts/release-start.sh <patch|minor|major> [--dry-run] [--no-push] [--worktree-dir PATH]
Examples:
./scripts/release-start.sh patch
./scripts/release-start.sh minor --dry-run
./scripts/release-start.sh major --worktree-dir ../paperclip-release-1.0.0
What it does:
- fetches the release remote and tags
- computes the next stable version from the latest stable tag
- creates or resumes branch release/X.Y.Z
- creates or resumes a dedicated worktree for that branch
- pushes the release branch to the remote by default
Notes:
- Stable publishes freeze a release train. If vX.Y.Z already exists locally,
remotely, or on npm, this script refuses to reuse release/X.Y.Z.
- Use --no-push only if you intentionally do not want the release branch on
GitHub yet.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
--no-push) push_branch=false ;;
--worktree-dir)
shift
[ $# -gt 0 ] || release_fail "--worktree-dir requires a path."
worktree_path="$1"
;;
-h|--help)
usage
exit 0
;;
*)
if [ -n "$bump_type" ]; then
release_fail "only one bump type may be provided."
fi
bump_type="$1"
;;
esac
shift
done
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
usage
exit 1
fi
release_remote="$(resolve_release_remote)"
fetch_release_remote "$release_remote"
last_stable_tag="$(get_last_stable_tag)"
current_stable_version="$(get_current_stable_version)"
target_stable_version="$(compute_bumped_version "$current_stable_version" "$bump_type")"
target_canary_version="$(next_canary_version "$target_stable_version")"
release_branch="$(release_branch_name "$target_stable_version")"
release_tag="v$target_stable_version"
if [ -z "$worktree_path" ]; then
worktree_path="$(default_release_worktree_path "$target_stable_version")"
fi
if stable_release_exists_anywhere "$target_stable_version" "$release_remote"; then
release_fail "release train $release_branch is frozen because $release_tag already exists locally, remotely, or version $target_stable_version is already on npm."
fi
branch_exists_local=false
branch_exists_remote=false
branch_worktree_path=""
created_worktree=false
created_branch=false
pushed_branch=false
if git_local_branch_exists "$release_branch"; then
branch_exists_local=true
fi
if git_remote_branch_exists "$release_branch" "$release_remote"; then
branch_exists_remote=true
fi
branch_worktree_path="$(git_worktree_path_for_branch "$release_branch")"
if [ -n "$branch_worktree_path" ]; then
worktree_path="$branch_worktree_path"
fi
if [ -e "$worktree_path" ] && ! path_is_worktree_for_branch "$worktree_path" "$release_branch"; then
release_fail "path $worktree_path already exists and is not a worktree for $release_branch."
fi
if [ -z "$branch_worktree_path" ]; then
if [ "$dry_run" = true ]; then
if [ "$branch_exists_local" = true ] || [ "$branch_exists_remote" = true ]; then
release_info "[dry-run] Would add worktree $worktree_path for existing branch $release_branch"
else
release_info "[dry-run] Would create branch $release_branch from $release_remote/master"
release_info "[dry-run] Would add worktree $worktree_path"
fi
else
if [ "$branch_exists_local" = true ]; then
git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch"
elif [ "$branch_exists_remote" = true ]; then
git -C "$REPO_ROOT" branch --track "$release_branch" "$release_remote/$release_branch"
git -C "$REPO_ROOT" worktree add "$worktree_path" "$release_branch"
created_branch=true
else
git -C "$REPO_ROOT" worktree add -b "$release_branch" "$worktree_path" "$release_remote/master"
created_branch=true
fi
created_worktree=true
fi
fi
if [ "$dry_run" = false ] && [ "$push_branch" = true ] && [ "$branch_exists_remote" = false ]; then
git -C "$worktree_path" push -u "$release_remote" "$release_branch"
pushed_branch=true
fi
if [ "$dry_run" = false ] && [ "$branch_exists_remote" = true ]; then
git -C "$worktree_path" branch --set-upstream-to "$release_remote/$release_branch" "$release_branch" >/dev/null 2>&1 || true
fi
release_info ""
release_info "==> Release train"
release_info " Remote: $release_remote"
release_info " Last stable tag: ${last_stable_tag:-<none>}"
release_info " Current stable version: $current_stable_version"
release_info " Bump: $bump_type"
release_info " Target stable version: $target_stable_version"
release_info " Next canary version: $target_canary_version"
release_info " Branch: $release_branch"
release_info " Tag (reserved until stable publish): $release_tag"
release_info " Worktree: $worktree_path"
release_info " Release notes path: $worktree_path/releases/v${target_stable_version}.md"
release_info ""
release_info "==> Status"
if [ -n "$branch_worktree_path" ]; then
release_info " ✓ Reusing existing worktree for $release_branch"
elif [ "$dry_run" = true ]; then
release_info " ✓ Dry run only; no branch or worktree created"
else
[ "$created_branch" = true ] && release_info " ✓ Created branch $release_branch"
[ "$created_worktree" = true ] && release_info " ✓ Created worktree $worktree_path"
fi
if [ "$branch_exists_remote" = true ]; then
release_info " ✓ Remote branch already exists on $release_remote"
elif [ "$dry_run" = true ] && [ "$push_branch" = true ]; then
release_info " [dry-run] Would push $release_branch to $release_remote"
elif [ "$push_branch" = true ] && [ "$pushed_branch" = true ]; then
release_info " ✓ Pushed $release_branch to $release_remote"
elif [ "$push_branch" = false ]; then
release_warn "release branch was not pushed. Stable publish will later refuse until the branch exists on $release_remote."
fi
release_info ""
release_info "Next steps:"
release_info " cd $worktree_path"
release_info " Draft or update releases/v${target_stable_version}.md"
release_info " ./scripts/release-preflight.sh canary $bump_type"
release_info " ./scripts/release.sh $bump_type --canary"
release_info ""
release_info "Merge rule:"
release_info " Merge $release_branch back to master without squash or rebase so tag $release_tag remains reachable from master."

View File

@@ -1,80 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# release.sh — Prepare and publish a Paperclip release.
#
# Stable release:
# ./scripts/release.sh patch
# ./scripts/release.sh minor --dry-run
#
# Canary release:
# ./scripts/release.sh patch --canary
# ./scripts/release.sh minor --canary --dry-run
#
# Canary releases publish prerelease versions such as 1.2.3-canary.0 under the
# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest".
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
CLI_DIR="$REPO_ROOT/cli"
TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md"
TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json"
channel=""
release_date=""
dry_run=false
canary=false
bump_type=""
skip_verify=false
tag_name=""
cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
./scripts/release.sh <patch|minor|major> [--canary] [--dry-run]
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify]
Examples:
./scripts/release.sh patch
./scripts/release.sh minor --dry-run
./scripts/release.sh patch --canary
./scripts/release.sh minor --canary --dry-run
./scripts/release.sh canary
./scripts/release.sh canary --date 2026-03-17 --dry-run
./scripts/release.sh stable
./scripts/release.sh stable --date 2026-03-17 --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".
- Run this from branch release/X.Y.Z matching the computed target version.
- Dry runs leave the working tree clean.
- Canary releases publish YYYY.M.D-canary.N under the npm dist-tag "canary"
and create the git tag canary/vYYYY.M.D-canary.N.
- Stable releases publish YYYY.M.D under the npm dist-tag "latest" and create
the git tag vYYYY.M.D.
- Stable release notes must already exist at releases/vYYYY.M.D.md.
- The script rewrites versions temporarily and restores the working tree on
exit. Tags always point at the original source commit, not a generated
release commit.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=true ;;
--canary) canary=true ;;
-h|--help)
usage
exit 0
;;
--promote)
echo "Error: --promote was removed. Re-run a stable release from the vetted commit instead."
exit 1
;;
*)
if [ -n "$bump_type" ]; then
echo "Error: only one bump type may be provided."
exit 1
fi
bump_type="$1"
;;
esac
shift
done
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
usage
exit 1
fi
restore_publish_artifacts() {
if [ -f "$CLI_DIR/package.dev.json" ]; then
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
@@ -91,8 +53,6 @@ restore_publish_artifacts() {
cleanup_release_state() {
restore_publish_artifacts
rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE"
tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)"
if [ -n "$tracked_changes" ]; then
printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do
@@ -114,260 +74,134 @@ cleanup_release_state() {
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_npm_publish_auth() {
if [ "$dry_run" = true ]; then
return
fi
while [ $# -gt 0 ]; do
case "$1" in
canary|stable)
if [ -n "$channel" ]; then
release_fail "only one release channel may be provided."
fi
channel="$1"
;;
--date)
shift
[ $# -gt 0 ] || release_fail "--date requires YYYY-MM-DD."
release_date="$1"
;;
--dry-run) dry_run=true ;;
--skip-verify) skip_verify=true ;;
-h|--help)
usage
exit 0
;;
*)
release_fail "unexpected argument: $1"
;;
esac
shift
done
if npm whoami >/dev/null 2>&1; then
release_info " ✓ Logged in to npm as $(npm whoami)"
return
fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return
fi
release_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 pkgPath = path.join(root, dir, 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const key = `${dir}\t${name}\t${pkg.version}`;
if (seen.has(key)) continue;
seen.add(key);
process.stdout.write(`${dir}\t${name}\t${pkg.version}\n`);
}
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
[ -n "$channel" ] || {
usage
exit 1
}
PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_REMOTE"
CURRENT_BRANCH="$(git_current_branch)"
CURRENT_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)"
LAST_STABLE_TAG="$(get_last_stable_tag)"
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
RELEASE_DATE="${release_date:-$(utc_date_iso)}"
TARGET_STABLE_VERSION="$(stable_version_for_date "$RELEASE_DATE")"
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
CURRENT_BRANCH="$(git_current_branch)"
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
RELEASE_TAG="v$TARGET_STABLE_VERSION"
DIST_TAG="latest"
if [ "$canary" = true ]; then
if [ "$channel" = "canary" ]; then
require_on_master_branch
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
DIST_TAG="canary"
tag_name="$(canary_tag_name "$TARGET_PUBLISH_VERSION")"
else
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
fi
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
release_fail "next stable version matches the current stable version. Refusing to publish."
fi
if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
release_fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
fi
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
require_clean_worktree
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
if git_local_tag_exists "$RELEASE_TAG" || git_remote_tag_exists "$RELEASE_TAG" "$PUBLISH_REMOTE"; then
release_fail "release train $EXPECTED_RELEASE_BRANCH is frozen because tag $RELEASE_TAG already exists locally or on $PUBLISH_REMOTE."
fi
if npm_version_exists "$TARGET_STABLE_VERSION"; then
release_fail "stable version $TARGET_STABLE_VERSION is already published on npm. Refusing to reuse release train $EXPECTED_RELEASE_BRANCH."
fi
if [ "$canary" = false ] && [ ! -f "$NOTES_FILE" ]; then
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
fi
if [ "$canary" = true ] && [ ! -f "$NOTES_FILE" ]; then
release_warn "stable release notes file is missing at $NOTES_FILE. Draft it before you finalize stable."
fi
if ! git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$PUBLISH_REMOTE"; then
if [ "$canary" = false ] && [ "$dry_run" = false ]; then
release_fail "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE. Run ./scripts/release-start.sh $bump_type first or push the branch before stable publish."
fi
release_warn "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE yet."
fi
require_npm_publish_auth "$dry_run"
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
release_fail "no public packages were found in the workspace."
[ -n "$PUBLIC_PACKAGE_INFO" ] || release_fail "no public packages were found in the workspace."
if [ "$channel" = "stable" ] && [ ! -f "$NOTES_FILE" ]; then
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
fi
if [ "$channel" = "canary" ] && [ -f "$NOTES_FILE" ]; then
release_info " ✓ Stable release notes already exist at $NOTES_FILE"
fi
if git_local_tag_exists "$tag_name" || git_remote_tag_exists "$tag_name" "$PUBLISH_REMOTE"; then
release_fail "git tag $tag_name already exists locally or on $PUBLISH_REMOTE."
fi
while IFS= read -r package_name; do
[ -z "$package_name" ] && continue
if npm_package_version_exists "$package_name" "$TARGET_PUBLISH_VERSION"; then
release_fail "npm version ${package_name}@${TARGET_PUBLISH_VERSION} already exists."
fi
done <<< "$PUBLIC_PACKAGE_NAMES"
release_info ""
release_info "==> Release plan"
release_info " Remote: $PUBLISH_REMOTE"
release_info " Channel: $channel"
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
release_info " Expected branch: $EXPECTED_RELEASE_BRANCH"
release_info " Source commit: $CURRENT_SHA"
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
release_info " Current stable version: $CURRENT_STABLE_VERSION"
if [ "$canary" = true ]; then
release_info " Target stable version: $TARGET_STABLE_VERSION"
release_info " Release date (UTC): $RELEASE_DATE"
release_info " Target stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
release_info " Canary version: $TARGET_PUBLISH_VERSION"
release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
else
release_info " Stable version: $TARGET_STABLE_VERSION"
release_info " Stable version: $TARGET_PUBLISH_VERSION"
fi
release_info " Dist-tag: $DIST_TAG"
release_info " Git tag: $tag_name"
if [ "$channel" = "stable" ]; then
release_info " Release notes: $NOTES_FILE"
fi
set_cleanup_trap
if [ "$skip_verify" = false ]; then
release_info ""
release_info "==> Step 1/7: Verification gate..."
cd "$REPO_ROOT"
pnpm -r typecheck
pnpm test:run
pnpm build
else
release_info ""
release_info "==> Step 1/7: Verification gate skipped (--skip-verify)"
fi
release_info ""
release_info "==> Step 1/7: Preflight checks..."
release_info " ✓ Working tree is clean"
release_info " ✓ Branch matches release train"
require_npm_publish_auth
if [ "$dry_run" = true ] || [ "$canary" = true ]; then
set_cleanup_trap
fi
release_info ""
release_info "==> Step 2/7: Creating release changeset..."
{
echo "---"
while IFS= read -r pkg_name; do
[ -z "$pkg_name" ] && continue
echo "\"$pkg_name\": $bump_type"
done <<< "$PUBLIC_PACKAGE_NAMES"
echo "---"
echo ""
if [ "$canary" = true ]; then
echo "Canary release preparation for $TARGET_STABLE_VERSION"
else
echo "Stable release preparation for $TARGET_STABLE_VERSION"
fi
} > "$TEMP_CHANGESET_FILE"
release_info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
release_info ""
release_info "==> Step 3/7: Versioning packages..."
cd "$REPO_ROOT"
if [ "$canary" = true ]; then
npx changeset pre enter canary
fi
npx changeset version
if [ "$canary" = true ]; then
BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0"
if [ "$TARGET_PUBLISH_VERSION" != "$BASE_CANARY_VERSION" ]; then
replace_version_string "$BASE_CANARY_VERSION" "$TARGET_PUBLISH_VERSION"
fi
fi
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi
release_info "==> Step 2/7: Rewriting workspace versions..."
set_public_package_version "$TARGET_PUBLISH_VERSION"
release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
release_info ""
release_info "==> Step 4/7: Building workspace artifacts..."
release_info "==> Step 3/7: Building workspace artifacts..."
cd "$REPO_ROOT"
pnpm build
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
@@ -378,42 +212,47 @@ done
release_info " ✓ Workspace build complete"
release_info ""
release_info "==> Step 5/7: Building publishable CLI bundle..."
release_info "==> Step 4/7: Building publishable CLI bundle..."
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks
release_info " ✓ CLI bundle ready"
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
while IFS= read -r pkg_dir; do
release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..."
while IFS=$'\t' read -r pkg_dir _pkg_name _pkg_version; do
[ -z "$pkg_dir" ] && continue
release_info " --- $pkg_dir ---"
cd "$REPO_ROOT/$pkg_dir"
npm pack --dry-run 2>&1 | tail -3
done <<< "$PUBLIC_PACKAGE_DIRS"
cd "$REPO_ROOT"
if [ "$canary" = true ]; then
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
pnpm publish --dry-run --no-git-checks --tag "$DIST_TAG" 2>&1 | tail -3
done <<< "$VERSIONED_PACKAGE_INFO"
release_info " [dry-run] Would create git tag $tag_name on $CURRENT_SHA"
else
if [ "$canary" = true ]; then
release_info "==> Step 6/7: Publishing canary to npm..."
npx changeset publish
release_info " Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else
release_info "==> Step 6/7: Publishing stable release to npm..."
npx changeset publish
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi
release_info "==> Step 5/7: Publishing packages to npm..."
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
[ -z "$pkg_dir" ] && continue
release_info " Publishing $pkg_name@$pkg_version"
cd "$REPO_ROOT/$pkg_dir"
pnpm publish --no-git-checks --tag "$DIST_TAG" --access public
done <<< "$VERSIONED_PACKAGE_INFO"
release_info " ✓ Published all packages under dist-tag $DIST_TAG"
fi
release_info ""
release_info "==> Post-publish verification: Confirming npm package availability..."
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 6/7: Skipping npm verification in dry-run mode..."
else
release_info "==> Step 6/7: Confirming npm package availability..."
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
MISSING_PUBLISHED_PACKAGES=""
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
while IFS=$'\t' read -r _pkg_dir pkg_name pkg_version; do
[ -z "$pkg_name" ] && continue
release_info " Checking $pkg_name@$pkg_version"
if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then
@@ -427,49 +266,32 @@ else
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}"
done <<< "$VERSIONED_PACKAGE_INFO"
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES. Inspect the changeset publish output before treating this release as good."
fi
[ -z "$MISSING_PUBLISHED_PACKAGES" ] || release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES"
release_info " ✓ Verified all versioned packages are available on npm"
fi
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 7/7: Cleaning up dry-run state..."
release_info " ✓ Dry run leaves the working tree unchanged"
elif [ "$canary" = true ]; then
release_info "==> Step 7/7: Cleaning up canary state..."
release_info " ✓ Canary state will be discarded after publish"
release_info "==> Step 7/7: Dry run complete..."
else
release_info "==> Step 7/7: Finalizing stable release commit..."
restore_publish_artifacts
git -C "$REPO_ROOT" add -u .changeset packages server cli
if [ -f "$REPO_ROOT/releases/v${TARGET_STABLE_VERSION}.md" ]; then
git -C "$REPO_ROOT" add "releases/v${TARGET_STABLE_VERSION}.md"
fi
git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION"
git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION"
release_info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
release_info "==> Step 7/7: Creating git tag..."
git -C "$REPO_ROOT" tag "$tag_name" "$CURRENT_SHA"
release_info " ✓ Created tag $tag_name on $CURRENT_SHA"
fi
release_info ""
if [ "$dry_run" = true ]; then
if [ "$canary" = true ]; then
release_info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
else
release_info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
fi
elif [ "$canary" = true ]; then
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@canary onboard"
release_info "Stable version remains: $CURRENT_STABLE_VERSION"
release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
else
release_info "Published stable v${TARGET_STABLE_VERSION}."
release_info "Next steps:"
release_info " git push ${PUBLISH_REMOTE} HEAD --follow-tags"
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
release_info " Open a PR from ${EXPECTED_RELEASE_BRANCH} to master and merge without squash or rebase"
if [ "$channel" = "canary" ]; then
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@canary onboard"
release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
else
release_info "Published stable ${TARGET_PUBLISH_VERSION}."
release_info "Next steps:"
release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
fi
fi

View File

@@ -12,8 +12,8 @@ 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
./scripts/rollback-latest.sh 2026.3.17
./scripts/rollback-latest.sh 2026.3.17 --dry-run
Notes:
- This repoints the npm dist-tag "latest" for every public package.
@@ -45,7 +45,7 @@ if [ -z "$version" ]; then
fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: version must be a stable semver like 1.2.2." >&2
echo "Error: version must be a stable calendar version like 2026.3.17." >&2
exit 1
fi

View File

@@ -9,6 +9,7 @@ import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
@@ -36,8 +37,10 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Activity as ActivityIcon,
Check,
ChevronDown,
ChevronRight,
Copy,
EyeOff,
Hexagon,
ListTree,
@@ -196,7 +199,9 @@ export function IssueDetail() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast();
const [moreOpen, setMoreOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const [detailTab, setDetailTab] = useState("comments");
const [secondaryOpen, setSecondaryOpen] = useState({
@@ -585,6 +590,22 @@ export function IssueDetail() {
return () => closePanel();
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
const copyIssueToClipboard = async () => {
if (!issue) return;
const decodeEntities = (text: string) => {
const el = document.createElement("textarea");
el.innerHTML = text;
return el.value;
};
const title = decodeEntities(issue.title);
const body = decodeEntities(issue.description ?? "");
const md = `# ${issue.identifier}: ${title}\n\n${body}`.trimEnd();
await navigator.clipboard.writeText(md);
setCopied(true);
pushToast({ title: "Copied to clipboard", tone: "success" });
setTimeout(() => setCopied(false), 2000);
};
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!issue) return null;
@@ -737,17 +758,34 @@ export function IssueDetail() {
</div>
)}
<Button
variant="ghost"
size="icon-xs"
className="ml-auto md:hidden shrink-0"
onClick={() => setMobilePropsOpen(true)}
title="Properties"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<div className="ml-auto flex items-center gap-0.5 md:hidden shrink-0">
<Button
variant="ghost"
size="icon-xs"
onClick={copyIssueToClipboard}
title="Copy issue as markdown"
>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setMobilePropsOpen(true)}
title="Properties"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
</div>
<div className="hidden md:flex items-center md:ml-auto shrink-0">
<Button
variant="ghost"
size="icon-xs"
onClick={copyIssueToClipboard}
title="Copy issue as markdown"
>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon-xs"