chore: add release train workflow

This commit is contained in:
Dotta
2026-03-09 13:55:30 -05:00
parent d20341c797
commit 469bfe3953
12 changed files with 911 additions and 599 deletions

View File

@@ -32,7 +32,7 @@ concurrency:
jobs: jobs:
verify: verify:
if: github.ref == 'refs/heads/master' if: startsWith(github.ref, 'refs/heads/release/')
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
permissions: permissions:
@@ -68,7 +68,7 @@ jobs:
run: pnpm build run: pnpm build
publish: publish:
if: github.ref == 'refs/heads/master' if: startsWith(github.ref, 'refs/heads/release/')
needs: verify needs: verify
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 45
@@ -115,9 +115,9 @@ jobs:
fi fi
./scripts/release.sh "${args[@]}" ./scripts/release.sh "${args[@]}"
- name: Push stable release commit and tag - name: Push stable release branch commit and tag
if: inputs.channel == 'stable' && !inputs.dry_run if: inputs.channel == 'stable' && !inputs.dry_run
run: git push origin HEAD:master --follow-tags run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags
- name: Create GitHub Release - name: Create GitHub Release
if: inputs.channel == 'stable' && !inputs.dry_run if: inputs.channel == 'stable' && !inputs.dry_run

View File

@@ -8,10 +8,11 @@ For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This
Use these scripts instead of older one-off publish commands: Use these scripts instead of older one-off publish commands:
- [`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-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/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/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag
## Why the CLI needs special packaging ## Why the CLI needs special packaging
@@ -87,7 +88,7 @@ This means:
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`. Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
The stable publish flow also creates the local release commit and git tag. Pushing the commit/tag and creating the GitHub Release happen afterward as separate maintainer steps. 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.
## Rollback model ## Rollback model
@@ -109,7 +110,7 @@ Recommended CI release setup:
- use npm trusted publishing via GitHub OIDC - use npm trusted publishing via GitHub OIDC
- require approval through the `npm-release` environment - require approval through the `npm-release` environment
- run releases from `master` - run releases from `release/X.Y.Z`
- use canary first, then stable - use canary first, then stable
## Related Files ## Related Files

View File

@@ -2,260 +2,138 @@
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface. Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
This document is intentionally practical: The release model is branch-driven:
- TL;DR command sequences are at the top. 1. Start a release train on `release/X.Y.Z`
- Detailed checklists come next. 2. Draft the stable changelog on that branch
- Motivation, failure handling, and rollback playbooks follow after that. 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
## Release Surfaces ## Release Surfaces
Every Paperclip release has four separate surfaces: Every release has four separate surfaces:
1. **Verification** — the exact git SHA must pass typecheck, tests, and build. 1. **Verification** — the exact git SHA passes typecheck, tests, and build
2. **npm**`paperclipai` and the public workspace packages are published. 2. **npm**`paperclipai` and public workspace packages are published
3. **GitHub** — the stable release gets a git tag and a GitHub Release. 3. **GitHub** — the stable release gets a git tag and GitHub Release
4. **Website / announcements** — the stable changelog is published externally and announced. 4. **Website / announcements** — the stable changelog is published externally and announced
Treat those as related but separate. npm can succeed while the GitHub Release is still pending. GitHub can be correct while the website changelog is stale. A maintainer release is done only when all four surfaces are handled. A release is done only when all four surfaces are handled.
## 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.
## TL;DR ## TL;DR
### Canary release ### 1. Start the release train
Use this when you want an installable prerelease without changing `latest`. Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub.
```bash ```bash
# 0. Confirm master already has the CI-owned lockfile refresh merged ./scripts/release-start.sh patch
# If package manifests changed recently, wait for the refresh-lockfile PR first. ```
# 1. Preflight the canary candidate That script:
./scripts/release-preflight.sh canary patch
# 2. Draft or update the stable changelog for the intended stable version - fetches the release remote and tags
VERSION=0.2.8 - 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 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." claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
```
# 3. Preview the canary release ### 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 --dry-run
# 4. Publish the canary
./scripts/release.sh patch --canary ./scripts/release.sh patch --canary
# 5. Smoke test what users will actually install
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
```
# Users install with: Users install canaries with:
```bash
npx paperclipai@canary onboard npx paperclipai@canary onboard
``` ```
Result: ### 4. Publish stable
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
- `latest` is unchanged
- no git tag is created
- no GitHub Release is created
- the working tree returns to clean after the script finishes
- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N`
### Stable release
Use this only after the canary SHA is good enough to become the public default.
```bash ```bash
# 0. Confirm master already has the CI-owned lockfile refresh merged
# If package manifests changed recently, wait for the refresh-lockfile PR first.
# 1. Start from the vetted commit
git checkout master
git pull
# 2. Preflight the stable candidate
./scripts/release-preflight.sh stable patch ./scripts/release-preflight.sh stable patch
# 3. Confirm the stable changelog exists
VERSION=0.2.8
ls "releases/v${VERSION}.md"
# 4. Preview the stable publish
./scripts/release.sh patch --dry-run ./scripts/release.sh patch --dry-run
# 5. Publish the stable release to npm and create the local release commit + tag
./scripts/release.sh patch ./scripts/release.sh patch
git push public-gh HEAD --follow-tags
# 6. Push the release commit and tag
git push public-gh HEAD:master --follow-tags
# 7. Create or update the GitHub Release from the pushed tag
./scripts/create-github-release.sh X.Y.Z ./scripts/create-github-release.sh X.Y.Z
``` ```
Result: Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase.
- npm gets stable `X.Y.Z` under dist-tag `latest` ## Release Branches
- a local git commit and tag `vX.Y.Z` are created
- after push, GitHub gets the matching Release
- the website and announcement steps still need to be handled manually
### Emergency rollback Paperclip uses one release branch per target stable version:
If `latest` is broken after publish, repoint it to the last known good stable version first, then work on the fix. - `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:
```bash ```bash
# Preview ./scripts/release-start.sh <patch|minor|major>
./scripts/rollback-latest.sh X.Y.Z --dry-run
# Roll back latest for every public package
./scripts/rollback-latest.sh X.Y.Z
``` ```
This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. Useful options:
### Standalone onboarding smoke
You already have a script for isolated onboarding verification:
```bash ```bash
HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ./scripts/release-start.sh patch --dry-run
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh ./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0
./scripts/release-start.sh patch --no-push
``` ```
This is the best existing fit when you want: The script is intentionally idempotent:
- a standalone Paperclip data dir - if `release/X.Y.Z` already exists locally, it reuses it
- a dedicated host port - if the branch already exists on the remote, it resumes it locally
- an end-to-end `npx paperclipai ... onboard` check - 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
In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes. ### 2. Write the stable changelog early
If you want to exercise onboarding from a fresh local checkout rather than npm, use: Create or update:
```bash
./scripts/clean-onboard-git.sh
```
That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing.
If you want to exercise onboarding from the current committed ref in your local repo, use:
```bash
./scripts/clean-onboard-ref.sh
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
```
This uses the current committed `HEAD` in a detached temp worktree. It does **not** include uncommitted local edits.
### GitHub Actions release
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens.
Use it from the Actions tab:
1. Choose `Release`
2. Choose `channel`: `canary` or `stable`
3. Choose `bump`: `patch`, `minor`, or `major`
4. Choose whether this is a `dry_run`
5. Run it from `master`
The workflow:
- reruns `typecheck`, `test:run`, and `build`
- gates publish behind the `npm-release` environment
- can publish canaries without touching `latest`
- can publish stable, push the release commit and tag, and create the GitHub Release
## Release Checklist
### Before any publish
- [ ] The working tree is clean, including untracked files
- [ ] The target branch and SHA are the ones you actually want to release
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`
- [ ] The required verification gate passed on that exact SHA
- [ ] The bump type is correct for the user-visible impact
- [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md`
- [ ] You know which previous stable version you would roll back to if needed
### Before a canary
- [ ] You are intentionally testing something that should be installable before it becomes default
- [ ] You are comfortable with users installing it via `npx paperclipai@canary onboard`
- [ ] You understand that each canary is a new immutable npm version such as `1.2.3-canary.1`
### Before a stable
- [ ] The candidate has already passed smoke testing
- [ ] The changelog should be the stable version only, for example `v1.2.3`
- [ ] You are ready to push the release commit and tag immediately after npm publish
- [ ] You are ready to create the GitHub Release immediately after the push
- [ ] You have a post-release website / announcement plan
### After a stable
- [ ] `npm view paperclipai@latest version` matches the new stable version
- [ ] The git tag exists on GitHub
- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md`
- [ ] The website changelog is updated
- [ ] Any announcement copy matches the shipped release, not the canary
## Verification Gate
The repository standard is:
```bash
pnpm -r typecheck
pnpm test:run
pnpm build
```
This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready.
The release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml) installs with `pnpm install --frozen-lockfile`. That is intentional. Releases must use the exact dependency graph already committed on `master`; if manifests changed and the CI-owned lockfile refresh has not landed yet, the release should fail until that prerequisite is merged.
For release work, prefer:
```bash
./scripts/release-preflight.sh canary <patch|minor|major>
./scripts/release-preflight.sh stable <patch|minor|major>
```
That script runs the verification gate and prints the computed target versions before you publish anything.
## Versioning Policy
### Stable versions
Stable releases use normal semver:
- `patch` for bug fixes
- `minor` for additive features, endpoints, and additive migrations
- `major` for destructive migrations, removed APIs, or other breaking behavior
### Canary versions
Canaries are semver prereleases of the **intended stable version**:
- `1.2.3-canary.0`
- `1.2.3-canary.1`
- `1.2.3-canary.2`
That gives you three useful properties:
1. Users can install the prerelease explicitly with `@canary`
2. `latest` stays safe
3. The stable changelog can remain just `v1.2.3`
We do **not** create separate changelog files for canary versions.
Concrete example:
- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0`
- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version
## Changelog Policy
The maintainer changelog source of truth is:
- `releases/vX.Y.Z.md` - `releases/vX.Y.Z.md`
@@ -268,14 +146,13 @@ Recommended structure:
- `Improvements` - `Improvements`
- `Fixes` - `Fixes`
- `Upgrade Guide` when needed - `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. Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative.
## Detailed Workflow ### 3. Run release preflight
### 1. Decide the bump From the `release/X.Y.Z` worktree:
Run preflight first:
```bash ```bash
./scripts/release-preflight.sh canary <patch|minor|major> ./scripts/release-preflight.sh canary <patch|minor|major>
@@ -283,70 +160,54 @@ Run preflight first:
./scripts/release-preflight.sh stable <patch|minor|major> ./scripts/release-preflight.sh stable <patch|minor|major>
``` ```
That command: The preflight script now checks all of the following before it runs the verification gate:
- verifies the worktree is clean, including untracked files - the worktree is clean, including untracked files
- shows the last stable tag and computed next versions - the current branch matches the computed `release/X.Y.Z`
- shows the commit range since the last stable tag - the release train is not frozen
- highlights migration and breaking-change signals - the target version is still free on npm
- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` - 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
If you want the raw inputs separately, review the range since the last stable tag: Then it runs:
```bash ```bash
LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) pnpm -r typecheck
git log "${LAST_TAG}..HEAD" --oneline --no-merges pnpm test:run
git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ pnpm build
git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/
git log "${LAST_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true
``` ```
Use the higher bump if there is any doubt. ### 4. Publish one or more canaries
### 2. Write the stable changelog first
Create or update:
```bash
VERSION=X.Y.Z
claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
```
This is deliberate. The release notes should describe the stable story, not the canary mechanics.
### 3. Publish one or more canaries
Run: Run:
```bash ```bash
./scripts/release.sh <patch|minor|major> --canary --dry-run
./scripts/release.sh <patch|minor|major> --canary ./scripts/release.sh <patch|minor|major> --canary
``` ```
What the script does: Result:
1. Verifies the working tree is clean - npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
2. Computes the intended stable version from the last stable tag - `latest` is unchanged
3. Computes the next canary ordinal from npm - no git tag is created
4. Versions the public packages to `X.Y.Z-canary.N` - no GitHub Release is created
5. Builds the workspace and publishable CLI - the worktree returns to clean after the script finishes
6. Publishes to npm under dist-tag `canary`
7. Cleans up the temporary versioning state so your branch returns to clean
This means the script is safe to repeat as many times as needed while iterating: Guardrails:
- `1.2.3-canary.0` - the script refuses to run from the wrong branch
- `1.2.3-canary.1` - the script refuses to publish from a frozen train
- `1.2.3-canary.2` - the canary is always derived from the next stable version
- if the stable notes file is missing, the script warns before you forget it
The target stable release can still remain `1.2.3`. Concrete example:
Guardrail: - 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
- the canary is always derived from the **next stable version** ### 5. Smoke test the canary
- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0`
- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release
### 4. Smoke test the canary
Run the actual install path in Docker: Run the actual install path in Docker:
@@ -361,165 +222,198 @@ 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 HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
``` ```
If you want to smoke onboarding from the current codebase rather than npm, run: If you want to exercise onboarding from the current committed ref instead of npm, use:
```bash ```bash
./scripts/clean-onboard-git.sh
./scripts/clean-onboard-ref.sh ./scripts/clean-onboard-ref.sh
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
./scripts/clean-onboard-ref.sh HEAD
``` ```
Minimum checks: Minimum checks:
- [ ] `npx paperclipai@canary onboard` installs - `npx paperclipai@canary onboard` installs
- [ ] onboarding completes without crashes - onboarding completes without crashes
- [ ] the server boots - the server boots
- [ ] the UI loads - the UI loads
- [ ] basic company creation and dashboard load work - basic company creation and dashboard load work
### 5. Publish stable from the vetted commit If smoke testing fails:
Once the candidate SHA is good, run the stable flow on that exact commit: 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
### 6. Publish stable from the same release branch
Once the branch head is vetted, run:
```bash ```bash
./scripts/release.sh <patch|minor|major> --dry-run
./scripts/release.sh <patch|minor|major> ./scripts/release.sh <patch|minor|major>
``` ```
What the script does: Stable publish:
1. Verifies the working tree is clean - publishes `X.Y.Z` to npm under `latest`
2. Versions the public packages to the stable semver - creates the local release commit
3. Builds the workspace and CLI publish bundle - creates the local tag `vX.Y.Z`
4. Publishes to npm under `latest`
5. Restores temporary publish artifacts
6. Creates the local release commit and git tag
What it does **not** do: Stable publish refuses to proceed if:
- it does not push for you - the current branch is not `release/X.Y.Z`
- it does not update the website - the remote release branch does not exist yet
- it does not announce the release for you - the stable notes file is missing
- the target tag already exists locally or remotely
- the stable version already exists on npm
### 6. Push the release and create the GitHub Release Those checks intentionally freeze the train after stable publish.
After a stable publish succeeds: ### 7. Push the stable branch commit and tag
After stable publish succeeds:
```bash ```bash
git push public-gh HEAD:master --follow-tags git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z ./scripts/create-github-release.sh X.Y.Z
``` ```
The GitHub release notes come from: The GitHub Release notes come from:
- `releases/vX.Y.Z.md` - `releases/vX.Y.Z.md`
### 7. Complete the external surfaces ### 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: After GitHub is correct:
- publish the changelog on the website - publish the changelog on the website
- write the announcement copy - write and send the announcement copy
- ensure public docs and install guidance point to the stable version - ensure public docs and install guidance point to the stable version
## GitHub Actions and npm Trusted Publishing ## GitHub Actions Release
If you want GitHub to own the actual npm publish, use [`.github/workflows/release.yml`](../.github/workflows/release.yml) together with npm trusted publishing. There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
Recommended setup: Use it from the Actions tab on the relevant `release/X.Y.Z` branch:
1. Configure the GitHub Actions workflow as a trusted publisher for **every public package** on npm 1. Choose `Release`
2. Use the `npm-release` GitHub environment with required reviewers 2. Choose `channel`: `canary` or `stable`
3. Run stable publishes from `master` only 3. Choose `bump`: `patch`, `minor`, or `major`
4. Keep the workflow manual via `workflow_dispatch` 4. Choose whether this is a `dry_run`
5. Run it from the release branch, not from `master`
Why this is the right shape: The workflow:
- no long-lived npm token needs to live in GitHub secrets - reruns `typecheck`, `test:run`, and `build`
- reviewers can approve the publish step at the environment gate - gates publish behind the `npm-release` environment
- the workflow reruns verification on the release SHA before publish - can publish canaries without touching `latest`
- stable and canary use the same mechanics - 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
## Failure Playbooks ## Failure Playbooks
### If the canary fails before publish
Nothing shipped. Fix the code and rerun the canary workflow.
### If the canary publishes but the smoke test fails ### If the canary publishes but the smoke test fails
Do **not** publish stable. Do not publish stable.
Instead: Instead:
1. Fix the issue 1. fix the issue on `release/X.Y.Z`
2. Publish another canary 2. publish another canary
3. Re-run smoke testing 3. rerun smoke testing
The canary version number will increase, but the stable target version can remain the same. ### If stable npm publish succeeds but push or GitHub release creation fails
### If the stable npm publish succeeds but push fails
This is a partial release. npm is already live. This is a partial release. npm is already live.
Do this immediately: Do this immediately:
1. Fix the git issue 1. fix the git or GitHub issue from the same checkout
2. Push the release commit and tag from the same checkout 2. push the stable branch commit and tag
3. Create the GitHub Release 3. create the GitHub Release
Do **not** publish the same version again. Do not republish the same version.
### If the stable release is bad after `latest` moves ### If `latest` is broken after stable publish
Use the rollback script first: Preview:
```bash ```bash
./scripts/rollback-latest.sh <last-good-version> ./scripts/rollback-latest.sh X.Y.Z --dry-run
``` ```
Then: Roll back:
1. open an incident note or maintainer comment ```bash
2. fix forward on a new patch release ./scripts/rollback-latest.sh X.Y.Z
3. update the changelog / release notes if the user-facing guidance changed ```
### If the GitHub Release is wrong This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
Edit it by re-running: Then fix forward with a new patch release.
### If the GitHub Release notes are wrong
Re-run:
```bash ```bash
./scripts/create-github-release.sh X.Y.Z ./scripts/create-github-release.sh X.Y.Z
``` ```
This updates the release notes if the GitHub Release already exists. If the release already exists, the script updates it.
### If the website changelog is wrong
Fix the website independently. Do not republish npm just to repair the website surface.
## Rollback Strategy
The default rollback strategy is **dist-tag rollback, then fix forward**.
Why:
- npm versions are immutable
- users need `npx paperclipai onboard` to recover quickly
- moving `latest` back is faster and safer than trying to delete history
Rollback procedure:
1. identify the last known good stable version
2. run `./scripts/rollback-latest.sh <version>`
3. verify `npm view paperclipai@latest version`
4. fix forward with a new stable release
## Scripts Reference
- [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release
- [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI
## Related Docs ## Related Docs

View File

@@ -18,6 +18,7 @@
"db:backup": "./scripts/backup-db.sh", "db:backup": "./scripts/backup-db.sh",
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
"build:npm": "./scripts/build-npm.sh", "build:npm": "./scripts/build-npm.sh",
"release:start": "./scripts/release-start.sh",
"release": "./scripts/release.sh", "release": "./scripts/release.sh",
"release:preflight": "./scripts/release-preflight.sh", "release:preflight": "./scripts/release-preflight.sh",
"release:github": "./scripts/create-github-release.sh", "release:github": "./scripts/create-github-release.sh",

View File

@@ -4,9 +4,9 @@
## Highlights ## Highlights
- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex. - **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex. ([#62](https://github.com/paperclipai/paperclip/pull/62), [#141](https://github.com/paperclipai/paperclip/pull/141), [#240](https://github.com/paperclipai/paperclip/pull/240), [#183](https://github.com/paperclipai/paperclip/pull/183), @aaaaron, @Konan69, @richardanaya)
- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation. - **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation. ([#270](https://github.com/paperclipai/paperclip/pull/270))
- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused. - **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused. ([#196](https://github.com/paperclipai/paperclip/pull/196), @hougangdev)
- **PWA support** — The UI ships as an installable Progressive Web App with a service worker and enhanced manifest. The service worker uses a network-first strategy to prevent stale content. - **PWA support** — The UI ships as an installable Progressive Web App with a service worker and enhanced manifest. The service worker uses a network-first strategy to prevent stale content.
- **Agent creation wizard** — A new choice modal and full-page configuration flow make it easier to add agents. The sidebar AGENTS header now has a quick-add button. - **Agent creation wizard** — A new choice modal and full-page configuration flow make it easier to add agents. The sidebar AGENTS header now has a quick-add button.
@@ -19,29 +19,35 @@
- **Project status clickable** — The status chip in the project properties pane is now clickable for quick updates. - **Project status clickable** — The status chip in the project properties pane is now clickable for quick updates.
- **Scroll-to-bottom button** — Issue detail and run pages show a floating scroll-to-bottom button when you scroll up. - **Scroll-to-bottom button** — Issue detail and run pages show a floating scroll-to-bottom button when you scroll up.
- **Database backup CLI** — `paperclipai db:backup` lets you snapshot the database on demand, with optional automatic scheduling. - **Database backup CLI** — `paperclipai db:backup` lets you snapshot the database on demand, with optional automatic scheduling.
- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration. - **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration. ([#279](https://github.com/paperclipai/paperclip/pull/279), @JasonOA888)
- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates. - **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates. ([#264](https://github.com/paperclipai/paperclip/pull/264), @mvanhorn)
- **Human-readable role labels** — The agent list and properties pane show friendly role names. - **Human-readable role labels** — The agent list and properties pane show friendly role names. ([#263](https://github.com/paperclipai/paperclip/pull/263), @mvanhorn)
- **Assignee picker sorting** — Recent selections appear first, then alphabetical. - **Assignee picker sorting** — Recent selections appear first, then alphabetical.
- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile. - **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile. ([#118](https://github.com/paperclipai/paperclip/pull/118), @MumuTW)
- **Invite UX improvements** — Invite links auto-copy to clipboard, snippet-only flow in settings, 10-minute invite TTL, and clearer network-host guidance. - **Invite UX improvements** — Invite links auto-copy to clipboard, snippet-only flow in settings, 10-minute invite TTL, and clearer network-host guidance.
- **Permalink anchors on comments** — Each comment has a stable anchor link and a GET-by-ID API endpoint. - **Permalink anchors on comments** — Each comment has a stable anchor link and a GET-by-ID API endpoint.
- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image. - **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image. ([#400](https://github.com/paperclipai/paperclip/pull/400), [#283](https://github.com/paperclipai/paperclip/pull/283), [#284](https://github.com/paperclipai/paperclip/pull/284), @AiMagic5000, @mingfang)
- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants. - **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants. ([#293](https://github.com/paperclipai/paperclip/pull/293), [#110](https://github.com/paperclipai/paperclip/pull/110), @cpfarhood, @artokun)
- **Playwright e2e tests** — New end-to-end test suite covering the onboarding wizard flow. - **Playwright e2e tests** — New end-to-end test suite covering the onboarding wizard flow.
## Fixes ## Fixes
- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking. - **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking. ([#261](https://github.com/paperclipai/paperclip/pull/261), @mvanhorn)
- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes. - **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes. ([#269](https://github.com/paperclipai/paperclip/pull/269), [#78](https://github.com/paperclipai/paperclip/pull/78), @mvanhorn, @MumuTW)
- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler. - **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler. ([#269](https://github.com/paperclipai/paperclip/pull/269), @mvanhorn)
- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers. - **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers. ([#159](https://github.com/paperclipai/paperclip/pull/159), [#154](https://github.com/paperclipai/paperclip/pull/154), [#267](https://github.com/paperclipai/paperclip/pull/267), [#72](https://github.com/paperclipai/paperclip/pull/72), @Logesh-waran2003, @cschneid, @mvanhorn, @STRML)
- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors. - **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors. ([#266](https://github.com/paperclipai/paperclip/pull/266), @mvanhorn)
- **500 error logging** — Error logs now include the actual error message and request context instead of generic pino-http output. - **500 error logging** — Error logs now include the actual error message and request context instead of generic pino-http output.
- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false. - **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false. ([#91](https://github.com/paperclipai/paperclip/pull/91), @zvictor)
- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode. - **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode. ([#91](https://github.com/paperclipai/paperclip/pull/91), @zvictor)
- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution. - **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution. ([#265](https://github.com/paperclipai/paperclip/pull/265), [#413](https://github.com/paperclipai/paperclip/pull/413), @mvanhorn, @online5880)
- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures. - **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures. ([#376](https://github.com/paperclipai/paperclip/pull/376), @dalestubblefield)
- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues. - **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues. ([#260](https://github.com/paperclipai/paperclip/pull/260), @mvanhorn)
- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode. - **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode. ([#99](https://github.com/paperclipai/paperclip/pull/99), @zvictor)
- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals. - **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals. ([#262](https://github.com/paperclipai/paperclip/pull/262), [#196](https://github.com/paperclipai/paperclip/pull/196), [#423](https://github.com/paperclipai/paperclip/pull/423), @mvanhorn, @hougangdev, @RememberV)
## Contributors
Thank you to everyone who contributed to this release!
@aaaaron, @AiMagic5000, @artokun, @cpfarhood, @cschneid, @dalestubblefield, @Dotta, @eltociear, @fahmmin, @gsxdsm, @hougangdev, @JasonOA888, @Konan69, @Logesh-waran2003, @mingfang, @MumuTW, @mvanhorn, @numman-ali, @online5880, @RememberV, @richardanaya, @STRML, @tylerwince, @zvictor

View File

@@ -2,7 +2,8 @@
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}" # shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
dry_run=false dry_run=false
version="" version=""
@@ -17,7 +18,7 @@ Examples:
./scripts/create-github-release.sh 1.2.3 --dry-run ./scripts/create-github-release.sh 1.2.3 --dry-run
Notes: Notes:
- Run this after pushing the release commit and tag. - Run this after pushing the stable release branch and tag.
- If the release already exists, this script updates its title and notes. - If the release already exists, this script updates its title and notes.
EOF EOF
} }
@@ -52,6 +53,7 @@ fi
tag="v$version" tag="v$version"
notes_file="$REPO_ROOT/releases/${tag}.md" notes_file="$REPO_ROOT/releases/${tag}.md"
PUBLISH_REMOTE="$(resolve_release_remote)"
if ! command -v gh >/dev/null 2>&1; then if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required to create GitHub releases." >&2 echo "Error: gh CLI is required to create GitHub releases." >&2

222
scripts/release-lib.sh Normal file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env bash
if [ -z "${REPO_ROOT:-}" ]; then
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fi
release_info() {
echo "$@"
}
release_warn() {
echo "Warning: $*" >&2
}
release_fail() {
echo "Error: $*" >&2
exit 1
}
git_remote_exists() {
git -C "$REPO_ROOT" remote get-url "$1" >/dev/null 2>&1
}
resolve_release_remote() {
local remote="${RELEASE_REMOTE:-${PUBLISH_REMOTE:-}}"
if [ -n "$remote" ]; then
git_remote_exists "$remote" || release_fail "git remote '$remote' does not exist."
printf '%s\n' "$remote"
return
fi
if git_remote_exists public-gh; then
printf 'public-gh\n'
return
fi
if git_remote_exists origin; then
printf 'origin\n'
return
fi
release_fail "no git remote found. Configure RELEASE_REMOTE or PUBLISH_REMOTE."
}
fetch_release_remote() {
git -C "$REPO_ROOT" fetch "$1" --prune --tags
}
get_last_stable_tag() {
git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1
}
get_current_stable_version() {
local tag
tag="$(get_last_stable_tag)"
if [ -z "$tag" ]; then
printf '0.0.0\n'
else
printf '%s\n' "${tag#v}"
fi
}
compute_bumped_version() {
node - "$1" "$2" <<'NODE'
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${current}`);
}
let [major, minor, patch] = match.slice(1).map(Number);
if (bump === 'patch') {
patch += 1;
} else if (bump === 'minor') {
minor += 1;
patch = 0;
} else if (bump === 'major') {
major += 1;
minor = 0;
patch = 0;
} else {
throw new Error(`unsupported bump type: ${bump}`);
}
process.stdout.write(`${major}.${minor}.${patch}`);
NODE
}
next_canary_version() {
local stable_version="$1"
local versions_json
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
node - "$stable_version" "$versions_json" <<'NODE'
const stable = process.argv[2];
const versionsArg = process.argv[3];
let versions = [];
try {
const parsed = JSON.parse(versionsArg);
versions = Array.isArray(parsed) ? parsed : [parsed];
} catch {
versions = [];
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
for (const version of versions) {
const match = version.match(pattern);
if (!match) continue;
max = Math.max(max, Number(match[1]));
}
process.stdout.write(`${stable}-canary.${max + 1}`);
NODE
}
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"
}
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" ]
}
require_clean_worktree() {
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
release_fail "working tree is not clean. Commit, stash, or remove changes before releasing."
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"
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."
fi
}
stable_release_exists_anywhere() {
local stable_version="$1"
local remote="$2"
local tag="v$stable_version"
git_local_tag_exists "$tag" || git_remote_tag_exists "$tag" "$remote" || npm_version_exists "$stable_version"
}
release_train_is_frozen() {
stable_release_exists_anywhere "$1" "$2"
}

View File

@@ -2,6 +2,8 @@
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
export GIT_PAGER=cat export GIT_PAGER=cat
channel="" channel=""
@@ -18,7 +20,9 @@ Examples:
What it does: What it does:
- verifies the git worktree is clean, including untracked files - 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 last stable tag and the target version(s)
- shows the git/npm/GitHub release-train state
- shows commits since the last stable tag - shows commits since the last stable tag
- highlights migration/schema/breaking-change signals - highlights migration/schema/breaking-change signals
- runs the verification gate: - runs the verification gate:
@@ -63,79 +67,19 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
exit 1 exit 1
fi fi
compute_bumped_version() { RELEASE_REMOTE="$(resolve_release_remote)"
node - "$1" "$2" <<'NODE' fetch_release_remote "$RELEASE_REMOTE"
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${current}`);
}
let [major, minor, patch] = match.slice(1).map(Number);
if (bump === 'patch') {
patch += 1;
} else if (bump === 'minor') {
minor += 1;
patch = 0;
} else if (bump === 'major') {
major += 1;
minor = 0;
patch = 0;
} else {
throw new Error(`unsupported bump type: ${bump}`);
}
process.stdout.write(`${major}.${minor}.${patch}`);
NODE
}
next_canary_version() {
local stable_version="$1"
local versions_json
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
node - "$stable_version" "$versions_json" <<'NODE'
const stable = process.argv[2];
const versionsArg = process.argv[3];
let versions = [];
try {
const parsed = JSON.parse(versionsArg);
versions = Array.isArray(parsed) ? parsed : [parsed];
} catch {
versions = [];
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
for (const version of versions) {
const match = version.match(pattern);
if (!match) continue;
max = Math.max(max, Number(match[1]));
}
process.stdout.write(`${stable}-canary.${max + 1}`);
NODE
}
LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)"
CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}"
if [ -z "$CURRENT_STABLE_VERSION" ]; then
CURRENT_STABLE_VERSION="0.0.0"
fi
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_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" 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")"
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then require_clean_worktree
echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2
exit 1
fi
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
echo "Error: next stable version matches the current stable version." >&2 echo "Error: next stable version matches the current stable version." >&2
@@ -147,10 +91,41 @@ if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
exit 1 exit 1
fi 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 ""
echo "==> Release preflight" echo "==> Release preflight"
echo " Remote: $RELEASE_REMOTE"
echo " Channel: $channel" echo " Channel: $channel"
echo " Bump: $bump_type" 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 " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION" echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION" echo " Next stable version: $TARGET_STABLE_VERSION"
@@ -162,6 +137,23 @@ fi
echo "" echo ""
echo "==> Working tree" echo "==> Working tree"
echo " ✓ Clean" 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 ""
echo "==> Commits since last stable tag" echo "==> Commits since last stable tag"
@@ -193,8 +185,10 @@ pnpm build
echo "" echo ""
echo "==> Release preflight summary" echo "==> Release preflight summary"
echo " Remote: $RELEASE_REMOTE"
echo " Channel: $channel" echo " Channel: $channel"
echo " Bump: $bump_type" echo " Bump: $bump_type"
echo " Release branch: $EXPECTED_RELEASE_BRANCH"
echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}" echo " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
echo " Current stable version: $CURRENT_STABLE_VERSION" echo " Current stable version: $CURRENT_STABLE_VERSION"
echo " Next stable version: $TARGET_STABLE_VERSION" echo " Next stable version: $TARGET_STABLE_VERSION"

182
scripts/release-start.sh Executable file
View File

@@ -0,0 +1,182 @@
#!/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

@@ -15,10 +15,11 @@ set -euo pipefail
# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest". # npm dist-tag "canary". Stable releases publish 1.2.3 under "latest".
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/release-lib.sh"
CLI_DIR="$REPO_ROOT/cli" CLI_DIR="$REPO_ROOT/cli"
TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md"
TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json" TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json"
PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}"
dry_run=false dry_run=false
canary=false canary=false
@@ -41,6 +42,7 @@ Notes:
- Canary publishes prerelease versions like 1.2.3-canary.0 under the npm - Canary publishes prerelease versions like 1.2.3-canary.0 under the npm
dist-tag "canary". dist-tag "canary".
- Stable publishes 1.2.3 under the npm dist-tag "latest". - 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. - Dry runs leave the working tree clean.
EOF EOF
} }
@@ -73,15 +75,6 @@ if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
exit 1 exit 1
fi fi
info() {
echo "$@"
}
fail() {
echo "Error: $*" >&2
exit 1
}
restore_publish_artifacts() { restore_publish_artifacts() {
if [ -f "$CLI_DIR/package.dev.json" ]; then if [ -f "$CLI_DIR/package.dev.json" ]; then
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
@@ -130,28 +123,22 @@ set_cleanup_trap() {
trap cleanup_release_state EXIT trap cleanup_release_state EXIT
} }
require_clean_worktree() {
if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then
fail "working tree is not clean. Commit, stash, or remove changes before releasing."
fi
}
require_npm_publish_auth() { require_npm_publish_auth() {
if [ "$dry_run" = true ]; then if [ "$dry_run" = true ]; then
return return
fi fi
if npm whoami >/dev/null 2>&1; then if npm whoami >/dev/null 2>&1; then
info " ✓ Logged in to npm as $(npm whoami)" release_info " ✓ Logged in to npm as $(npm whoami)"
return return
fi fi
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing" release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
return return
fi fi
fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow." release_fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
} }
list_public_package_info() { list_public_package_info() {
@@ -202,66 +189,6 @@ for (const [dir, name] of rows) {
NODE NODE
} }
compute_bumped_version() {
node - "$1" "$2" <<'NODE'
const current = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) {
throw new Error(`invalid semver version: ${current}`);
}
let [major, minor, patch] = match.slice(1).map(Number);
if (bump === 'patch') {
patch += 1;
} else if (bump === 'minor') {
minor += 1;
patch = 0;
} else if (bump === 'major') {
major += 1;
minor = 0;
patch = 0;
} else {
throw new Error(`unsupported bump type: ${bump}`);
}
process.stdout.write(`${major}.${minor}.${patch}`);
NODE
}
next_canary_version() {
local stable_version="$1"
local versions_json
versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')"
node - "$stable_version" "$versions_json" <<'NODE'
const stable = process.argv[2];
const versionsArg = process.argv[3];
let versions = [];
try {
const parsed = JSON.parse(versionsArg);
versions = Array.isArray(parsed) ? parsed : [parsed];
} catch {
versions = [];
}
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
let max = -1;
for (const version of versions) {
const match = version.match(pattern);
if (!match) continue;
max = Math.max(max, Number(match[1]));
}
process.stdout.write(`${stable}-canary.${max + 1}`);
NODE
}
replace_version_string() { replace_version_string() {
local from_version="$1" local from_version="$1"
local to_version="$2" local to_version="$2"
@@ -312,25 +239,55 @@ for (const relFile of extraFiles) {
NODE NODE
} }
LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)" PUBLISH_REMOTE="$(resolve_release_remote)"
CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}" fetch_release_remote "$PUBLISH_REMOTE"
if [ -z "$CURRENT_STABLE_VERSION" ]; then
CURRENT_STABLE_VERSION="0.0.0" LAST_STABLE_TAG="$(get_last_stable_tag)"
fi CURRENT_STABLE_VERSION="$(get_current_stable_version)"
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION" 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"
if [ "$canary" = true ]; then if [ "$canary" = true ]; then
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
fi fi
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
fail "next stable version matches the current stable version. Refusing to publish." release_fail "next stable version matches the current stable version. Refusing to publish."
fi fi
if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N." release_fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
fi
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 fi
PUBLIC_PACKAGE_INFO="$(list_public_package_info)" PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
@@ -338,33 +295,36 @@ PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)"
PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)" PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)"
if [ -z "$PUBLIC_PACKAGE_INFO" ]; then if [ -z "$PUBLIC_PACKAGE_INFO" ]; then
fail "no public packages were found in the workspace." release_fail "no public packages were found in the workspace."
fi fi
info "" release_info ""
info "==> Release plan" release_info "==> Release plan"
info " Last stable tag: ${LAST_STABLE_TAG:-<none>}" release_info " Remote: $PUBLISH_REMOTE"
info " Current stable version: $CURRENT_STABLE_VERSION" release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
release_info " Expected branch: $EXPECTED_RELEASE_BRANCH"
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
release_info " Current stable version: $CURRENT_STABLE_VERSION"
if [ "$canary" = true ]; then if [ "$canary" = true ]; then
info " Target stable version: $TARGET_STABLE_VERSION" release_info " Target stable version: $TARGET_STABLE_VERSION"
info " Canary version: $TARGET_PUBLISH_VERSION" release_info " Canary version: $TARGET_PUBLISH_VERSION"
info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N" release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
else else
info " Stable version: $TARGET_STABLE_VERSION" release_info " Stable version: $TARGET_STABLE_VERSION"
fi fi
info "" release_info ""
info "==> Step 1/7: Preflight checks..." release_info "==> Step 1/7: Preflight checks..."
require_clean_worktree release_info " ✓ Working tree is clean"
info " ✓ Working tree is clean" release_info " ✓ Branch matches release train"
require_npm_publish_auth require_npm_publish_auth
if [ "$dry_run" = true ] || [ "$canary" = true ]; then if [ "$dry_run" = true ] || [ "$canary" = true ]; then
set_cleanup_trap set_cleanup_trap
fi fi
info "" release_info ""
info "==> Step 2/7: Creating release changeset..." release_info "==> Step 2/7: Creating release changeset..."
{ {
echo "---" echo "---"
while IFS= read -r pkg_name; do while IFS= read -r pkg_name; do
@@ -379,10 +339,10 @@ info "==> Step 2/7: Creating release changeset..."
echo "Stable release preparation for $TARGET_STABLE_VERSION" echo "Stable release preparation for $TARGET_STABLE_VERSION"
fi fi
} > "$TEMP_CHANGESET_FILE" } > "$TEMP_CHANGESET_FILE"
info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages" release_info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
info "" release_info ""
info "==> Step 3/7: Versioning packages..." release_info "==> Step 3/7: Versioning packages..."
cd "$REPO_ROOT" cd "$REPO_ROOT"
if [ "$canary" = true ]; then if [ "$canary" = true ]; then
npx changeset pre enter canary npx changeset pre enter canary
@@ -398,12 +358,12 @@ fi
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")" VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE." release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi fi
info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION" release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
info "" release_info ""
info "==> Step 4/7: Building workspace artifacts..." release_info "==> Step 4/7: Building workspace artifacts..."
cd "$REPO_ROOT" cd "$REPO_ROOT"
pnpm build pnpm build
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
@@ -411,49 +371,49 @@ for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-loc
rm -rf "$REPO_ROOT/$pkg_dir/skills" rm -rf "$REPO_ROOT/$pkg_dir/skills"
cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills" cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills"
done done
info " ✓ Workspace build complete" release_info " ✓ Workspace build complete"
info "" release_info ""
info "==> Step 5/7: Building publishable CLI bundle..." release_info "==> Step 5/7: Building publishable CLI bundle..."
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks "$REPO_ROOT/scripts/build-npm.sh" --skip-checks
info " ✓ CLI bundle ready" release_info " ✓ CLI bundle ready"
info "" release_info ""
if [ "$dry_run" = true ]; then if [ "$dry_run" = true ]; then
info "==> Step 6/7: Previewing publish payloads (--dry-run)..." release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
while IFS= read -r pkg_dir; do while IFS= read -r pkg_dir; do
[ -z "$pkg_dir" ] && continue [ -z "$pkg_dir" ] && continue
info " --- $pkg_dir ---" release_info " --- $pkg_dir ---"
cd "$REPO_ROOT/$pkg_dir" cd "$REPO_ROOT/$pkg_dir"
npm pack --dry-run 2>&1 | tail -3 npm pack --dry-run 2>&1 | tail -3
done <<< "$PUBLIC_PACKAGE_DIRS" done <<< "$PUBLIC_PACKAGE_DIRS"
cd "$REPO_ROOT" cd "$REPO_ROOT"
if [ "$canary" = true ]; then if [ "$canary" = true ]; then
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary" release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else else
info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest" release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi fi
else else
if [ "$canary" = true ]; then if [ "$canary" = true ]; then
info "==> Step 6/7: Publishing canary to npm..." release_info "==> Step 6/7: Publishing canary to npm..."
npx changeset publish npx changeset publish
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
else else
info "==> Step 6/7: Publishing stable release to npm..." release_info "==> Step 6/7: Publishing stable release to npm..."
npx changeset publish npx changeset publish
info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest" release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
fi fi
fi fi
info "" release_info ""
if [ "$dry_run" = true ]; then if [ "$dry_run" = true ]; then
info "==> Step 7/7: Cleaning up dry-run state..." release_info "==> Step 7/7: Cleaning up dry-run state..."
info " ✓ Dry run leaves the working tree unchanged" release_info " ✓ Dry run leaves the working tree unchanged"
elif [ "$canary" = true ]; then elif [ "$canary" = true ]; then
info "==> Step 7/7: Cleaning up canary state..." release_info "==> Step 7/7: Cleaning up canary state..."
info " ✓ Canary state will be discarded after publish" release_info " ✓ Canary state will be discarded after publish"
else else
info "==> Step 7/7: Finalizing stable release commit..." release_info "==> Step 7/7: Finalizing stable release commit..."
restore_publish_artifacts restore_publish_artifacts
git -C "$REPO_ROOT" add -u .changeset packages server cli git -C "$REPO_ROOT" add -u .changeset packages server cli
@@ -463,23 +423,24 @@ else
git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION" git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION"
git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION" git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION"
info " ✓ Created commit and tag v$TARGET_STABLE_VERSION" release_info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
fi fi
info "" release_info ""
if [ "$dry_run" = true ]; then if [ "$dry_run" = true ]; then
if [ "$canary" = true ]; then if [ "$canary" = true ]; then
info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}." release_info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
else else
info "Dry run complete for stable v${TARGET_STABLE_VERSION}." release_info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
fi fi
elif [ "$canary" = true ]; then elif [ "$canary" = true ]; then
info "Published canary ${TARGET_PUBLISH_VERSION}." release_info "Published canary ${TARGET_PUBLISH_VERSION}."
info "Install with: npx paperclipai@canary onboard" release_info "Install with: npx paperclipai@canary onboard"
info "Stable version remains: $CURRENT_STABLE_VERSION" release_info "Stable version remains: $CURRENT_STABLE_VERSION"
else else
info "Published stable v${TARGET_STABLE_VERSION}." release_info "Published stable v${TARGET_STABLE_VERSION}."
info "Next steps:" release_info "Next steps:"
info " git push ${PUBLISH_REMOTE} HEAD:master --follow-tags" release_info " git push ${PUBLISH_REMOTE} HEAD --follow-tags"
info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" 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"
fi fi

View File

@@ -106,6 +106,25 @@ Guidelines:
- keep highlights short and concrete - keep highlights short and concrete
- spell out upgrade actions for breaking changes - spell out upgrade actions for breaking changes
### Inline PR and contributor attribution
When a bullet item clearly maps to a merged pull request, add inline attribution at the
end of the entry in this format:
```
- **Feature name** — Description. ([#123](https://github.com/paperclipai/paperclip/pull/123), @contributor1, @contributor2)
```
Rules:
- Only add a PR link when you can confidently trace the bullet to a specific merged PR.
Use merge commit messages (`Merge pull request #N from user/branch`) to map PRs.
- List the contributor(s) who authored the PR. Use GitHub usernames, not real names or emails.
- If multiple PRs contributed to a single bullet, list them all: `([#10](url), [#12](url), @user1, @user2)`.
- If you cannot determine the PR number or contributor with confidence, omit the attribution
parenthetical — do not guess.
- Core maintainer commits that don't have an external PR can omit the parenthetical.
## Step 5 — Write the File ## Step 5 — Write the File
Template: Template:
@@ -124,10 +143,29 @@ Template:
## Fixes ## Fixes
## Upgrade Guide ## Upgrade Guide
## Contributors
Thank you to everyone who contributed to this release!
@username1, @username2, @username3
``` ```
Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist. Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist.
The `Contributors` section should always be included. List every person who authored
commits in the release range, @-mentioning them by their **GitHub username** (not their
real name or email). To find GitHub usernames:
1. Extract usernames from merge commit messages: `git log v{last}..HEAD --oneline --merges` — the branch prefix (e.g. `from username/branch`) gives the GitHub username.
2. For noreply emails like `user@users.noreply.github.com`, the username is the part before `@`.
3. For contributors whose username is ambiguous, check `gh api users/{guess}` or the PR page.
**Never expose contributor email addresses.** Use `@username` only.
Exclude bot accounts (e.g. `lockfile-bot`, `dependabot`) from the list. List contributors
in alphabetical order by GitHub username (case-insensitive).
## Step 6 — Review Before Release ## Step 6 — Review Before Release
Before handing it off: Before handing it off:

View File

@@ -13,10 +13,11 @@ Run the full Paperclip release as a maintainer workflow, not just an npm publish
This skill coordinates: This skill coordinates:
- stable changelog drafting via `release-changelog` - stable changelog drafting via `release-changelog`
- release-train setup via `scripts/release-start.sh`
- prerelease canary publishing via `scripts/release.sh --canary` - prerelease canary publishing via `scripts/release.sh --canary`
- Docker smoke testing via `scripts/docker-onboard-smoke.sh` - Docker smoke testing via `scripts/docker-onboard-smoke.sh`
- stable publishing via `scripts/release.sh` - stable publishing via `scripts/release.sh`
- pushing the release commit and tag - pushing the stable branch commit and tag
- GitHub Release creation via `scripts/create-github-release.sh` - GitHub Release creation via `scripts/create-github-release.sh`
- website / announcement follow-up tasks - website / announcement follow-up tasks
@@ -36,7 +37,7 @@ Before proceeding, verify all of the following:
2. The repo working tree is clean, including untracked files. 2. The repo working tree is clean, including untracked files.
3. There are commits since the last stable tag. 3. There are commits since the last stable tag.
4. The release SHA has passed the verification gate or is about to. 4. The release SHA has passed the verification gate or is about to.
5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`. 5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the release branch is cut.
6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. 6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing.
7. If running through Paperclip, you have issue context for status updates and follow-up task creation. 7. If running through Paperclip, you have issue context for status updates and follow-up task creation.
@@ -55,13 +56,15 @@ Collect these inputs up front:
Paperclip now uses this release model: Paperclip now uses this release model:
1. Draft the **stable** changelog as `releases/vX.Y.Z.md` 1. Start or resume `release/X.Y.Z`
2. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0` 2. Draft the **stable** changelog as `releases/vX.Y.Z.md`
3. Smoke test the canary via Docker 3. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0`
4. Publish the stable version `X.Y.Z` 4. Smoke test the canary via Docker
5. Push the release commit and tag 5. Publish the stable version `X.Y.Z`
6. Create the GitHub Release 6. Push the stable branch commit and tag
7. Complete website and announcement surfaces 7. Create the GitHub Release
8. Merge `release/X.Y.Z` back to `master` without squash or rebase
9. Complete website and announcement surfaces
Critical consequence: Critical consequence:
@@ -70,7 +73,13 @@ Critical consequence:
## Step 1 — Decide the Stable Version ## Step 1 — Decide the Stable Version
Run release preflight first: Start the release train first:
```bash
./scripts/release-start.sh {patch|minor|major}
```
Then run release preflight:
```bash ```bash
./scripts/release-preflight.sh canary {patch|minor|major} ./scripts/release-preflight.sh canary {patch|minor|major}
@@ -125,7 +134,7 @@ The GitHub Actions release workflow installs with `pnpm install --frozen-lockfil
## Step 4 — Publish a Canary ## Step 4 — Publish a Canary
Run: Run from the `release/X.Y.Z` branch:
```bash ```bash
./scripts/release.sh {patch|minor|major} --canary --dry-run ./scripts/release.sh {patch|minor|major} --canary --dry-run
@@ -203,12 +212,14 @@ Stable publish does **not** push the release for you.
After stable publish succeeds: After stable publish succeeds:
```bash ```bash
git push public-gh HEAD:master --follow-tags git push public-gh HEAD --follow-tags
./scripts/create-github-release.sh X.Y.Z ./scripts/create-github-release.sh X.Y.Z
``` ```
Use the stable changelog file as the GitHub Release notes source. Use the stable changelog file as the GitHub Release notes source.
Then open the PR from `release/X.Y.Z` back to `master` and merge without squash or rebase.
## Step 8 — Finish the Other Surfaces ## Step 8 — Finish the Other Surfaces
Create or verify follow-up work for: Create or verify follow-up work for: