chore: automate canary and stable releases

This commit is contained in:
Dotta
2026-03-17 14:08:55 -05:00
parent 7b9718cbaa
commit 21c1235277
18 changed files with 1536 additions and 1260 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

94
.github/workflows/release-canary.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
name: Release Canary
on:
push:
branches:
- master
concurrency:
group: release-canary-master
cancel-in-progress: false
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Typecheck
run: pnpm -r typecheck
- name: Run tests
run: pnpm test:run
- name: Build
run: pnpm build
publish:
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-canary
permissions:
contents: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm install --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 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}"

View File

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

View File

@@ -1,18 +1,19 @@
# Publishing to npm # 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 ## 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.sh`](../scripts/release.sh) for canary and stable publish flows
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing a stable tag
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes - [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest`
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback - [`scripts/build-npm.sh`](../scripts/build-npm.sh) for the CLI packaging build
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag
Paperclip no longer uses release branches or Changesets for publishing.
## Why the CLI needs special packaging ## Why the CLI needs special packaging
@@ -23,7 +24,7 @@ The CLI package, `paperclipai`, imports code from workspace packages such as:
- `@paperclipai/shared` - `@paperclipai/shared`
- adapter packages under `packages/adapters/` - 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` ## `build-npm.sh`
@@ -33,89 +34,107 @@ Run:
./scripts/build-npm.sh ./scripts/build-npm.sh
``` ```
This script does six things: This script:
1. Runs the forbidden token check unless `--skip-checks` is supplied 1. runs the forbidden token check unless `--skip-checks` is supplied
2. Runs `pnpm -r typecheck` 2. runs `pnpm -r typecheck`
3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js` 3. bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
4. Verifies the bundled entrypoint with `node --check` 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` 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 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. Public packages are discovered from:
During release preparation:
- `cli/package.json` becomes a publishable manifest with external npm dependency ranges
- `cli/package.dev.json` stores the development manifest temporarily
- `cli/dist/index.js` contains the bundled CLI entrypoint
- `cli/README.md` is copied in for npm metadata
After release finalization, the release script restores the development manifest and removes the temporary README copy.
## Package discovery
The release tooling scans the workspace for public packages under:
- `packages/` - `packages/`
- `server/` - `server/`
- `cli/` - `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` ## Version formats
- `1.2.3-canary.1`
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 Examples:
- `npx paperclipai onboard` continues to resolve `latest`
- the stable changelog can stay at `releases/v1.2.3.md`
## 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 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 ```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. This is the fastest way to restore the default install path if a stable release is bad.
## 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
## Related Files ## Related Files
- [`scripts/build-npm.sh`](../scripts/build-npm.sh) - [`scripts/build-npm.sh`](../scripts/build-npm.sh)
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs) - [`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) - [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
- [`doc/RELEASING.md`](RELEASING.md) - [`doc/RELEASING.md`](RELEASING.md)

View File

@@ -0,0 +1,271 @@
# 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-canary.yml`
- `.github/workflows/release-stable.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-canary.yml`
- `.github/workflows/release-stable.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 two trusted publisher entries per package
Because npm trusted publishing is tied to the workflow filename, configure both:
- workflow: `.github/workflows/release-canary.yml`
- workflow: `.github/workflows/release-stable.yml`
Repository:
- `paperclipai/paperclip`
Branch expectations:
- canary workflow should only ever run from `master`
- stable workflow is manual but should also be restricted to `master` by GitHub environment policy
### 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-canary.yml`
- `.github/workflows/release-stable.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 Canary` workflow run
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 Stable`
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 # 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` 1. Every push to `master` publishes a canary automatically.
2. Draft the stable changelog on that branch 2. Stable releases are manually promoted from a chosen tested commit or canary tag.
3. Publish one or more canaries from that branch 3. Stable release notes live in `releases/vYYYY.M.D.md`.
4. Publish stable from that same branch head 4. Only stable releases get GitHub Releases.
5. Push the branch commit and tag
6. Create the GitHub Release ## Versioning Model
7. Merge `release/X.Y.Z` back to `master` without squash or rebase
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 ## 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 1. **Verification** — the exact git SHA passes typecheck, tests, and build
2. **npm**`paperclipai` and public workspace packages are published 2. **npm**`paperclipai` and public workspace packages are published
3. **GitHub** — the stable release gets a git tag and 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
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 ## Core Invariants
- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch. - canaries publish from `master`
- The release scripts must run from the matching `release/X.Y.Z` branch. - stables publish from an explicitly chosen source ref
- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen. - tags point at the original source commit, not a generated release commit
- Do not squash-merge or rebase-merge a release branch PR back to `master`. - stable notes are always `releases/vYYYY.M.D.md`
- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files. - canaries never create GitHub Releases
- canaries never require changelog generation
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
### 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 [`.github/workflows/release-canary.yml`](../.github/workflows/release-canary.yml).
```bash It:
./scripts/release-start.sh patch
```
That script: - verifies the pushed commit
- computes the canary version for the current UTC date
- fetches the release remote and tags - publishes under npm dist-tag `canary`
- computes the next stable version from the latest `v*` tag - creates a git tag `canary/vYYYY.M.D-canary.N`
- 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
```
Users install canaries with: Users install canaries with:
@@ -76,145 +68,91 @@ Users install canaries with:
npx paperclipai@canary onboard npx paperclipai@canary onboard
``` ```
### 4. Publish stable ### Stable
Use [`.github/workflows/release-stable.yml`](../.github/workflows/release-stable.yml) from the Actions tab.
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 ```bash
./scripts/release-preflight.sh stable patch ./scripts/release.sh canary --dry-run
./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
``` ```
Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase. ### Preview a stable locally
## 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:
```bash ```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 ```bash
./scripts/release-start.sh patch --dry-run ./scripts/release.sh stable
./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0 git push public-gh refs/tags/vYYYY.M.D
./scripts/release-start.sh patch --no-push ./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 Stable changelog files live at:
- 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
### 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` Recommended local generation flow:
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:
```bash ```bash
./scripts/release-preflight.sh canary <patch|minor|major> VERSION=2026.3.17
# or 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."
./scripts/release-preflight.sh stable <patch|minor|major>
``` ```
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 - canaries are too frequent
- the current branch matches the computed `release/X.Y.Z` - stable notes are the only public narrative surface that needs LLM help
- the release train is not frozen - maintainer LLM tokens should not live in Actions
- 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
Then it runs: ## Smoke Testing
```bash For a canary:
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:
```bash ```bash
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
``` ```
For the current stable:
```bash
PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
```
Useful isolated variants: Useful isolated variants:
```bash ```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 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: Minimum checks:
- `npx paperclipai@canary onboard` installs - `npx paperclipai@canary onboard` installs
@@ -238,185 +168,59 @@ Minimum checks:
- the UI loads - the UI loads
- basic company creation and dashboard load work - basic company creation and dashboard load work
If smoke testing fails: ## Rollback
1. stop the stable release Rollback does not unpublish versions.
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 It only moves the `latest` dist-tag back to a previous stable:
Once the branch head is vetted, run:
```bash ```bash
./scripts/release.sh <patch|minor|major> --dry-run ./scripts/rollback-latest.sh 2026.3.16 --dry-run
./scripts/release.sh <patch|minor|major> ./scripts/rollback-latest.sh 2026.3.16
``` ```
Stable publish: Then fix forward with a new stable release date.
- 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
## Failure Playbooks ## 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: Instead:
1. fix the issue on `release/X.Y.Z` 1. fix the issue on `master`
2. publish another canary 2. merge the fix
3. rerun smoke testing 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. This is a partial release. npm is already live.
Do this immediately: Do this immediately:
1. fix the git or GitHub issue from the same checkout 1. push the missing tag
2. push the stable branch commit and tag 2. rerun `./scripts/create-github-release.sh YYYY.M.D`
3. create the GitHub Release 3. verify the GitHub Release notes point at `releases/vYYYY.M.D.md`
Do not republish the same version. Do not republish the same version.
### If `latest` is broken after stable publish ### If `latest` is broken after stable publish
Preview: Roll back the dist-tag:
```bash ```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 ## Related Files
./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. - [`scripts/release.sh`](../scripts/release.sh)
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
Then fix forward with a new patch release. - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh)
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh)
### If the GitHub Release notes are wrong - [`doc/PUBLISHING.md`](PUBLISHING.md)
- [`doc/RELEASE-AUTOMATION-SETUP.md`](RELEASE-AUTOMATION-SETUP.md)
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

View File

@@ -0,0 +1,494 @@
# 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. Split canary and stable into separate workflow files
Do not use one workflow file for both.
Recommended:
- `.github/workflows/release-canary.yml`
- `.github/workflows/release-stable.yml`
Why:
- npm trusted publishing is configured per workflow filename
- canary and stable need different blast radii
- stable should have stronger GitHub environment rules than canary
#### 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-canary.yml` and `release-stable.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", "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:canary": "./scripts/release.sh canary",
"release:stable": "./scripts/release.sh stable",
"release:github": "./scripts/create-github-release.sh", "release:github": "./scripts/create-github-release.sh",
"release:rollback": "./scripts/rollback-latest.sh", "release:rollback": "./scripts/rollback-latest.sh",
"changeset": "changeset",
"version-packages": "changeset version",
"check:tokens": "node scripts/check-forbidden-tokens.mjs", "check:tokens": "node scripts/check-forbidden-tokens.mjs",
"docs:dev": "cd docs && npx mintlify dev", "docs:dev": "cd docs && npx mintlify dev",
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
@@ -34,7 +32,6 @@
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed" "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.30.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",

View File

@@ -14,11 +14,11 @@ Usage:
./scripts/create-github-release.sh <version> [--dry-run] ./scripts/create-github-release.sh <version> [--dry-run]
Examples: Examples:
./scripts/create-github-release.sh 1.2.3 ./scripts/create-github-release.sh 2026.3.17
./scripts/create-github-release.sh 1.2.3 --dry-run ./scripts/create-github-release.sh 2026.3.17 --dry-run
Notes: Notes:
- Run this after pushing the stable release branch and tag. - Run this after pushing the stable tag.
- Defaults to git remote public-gh. - Defaults to git remote public-gh.
- 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
@@ -48,7 +48,7 @@ if [ -z "$version" ]; then
fi fi
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 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 exit 1
fi fi

View File

@@ -37,7 +37,7 @@ const workspacePaths = [
]; ];
// Workspace packages that are NOT bundled and must stay as npm dependencies. // 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([ const externalWorkspacePackages = new Set([
"@paperclipai/server", "@paperclipai/server",
]); ]);
@@ -57,7 +57,7 @@ for (const pkgPath of workspacePaths) {
if (externalWorkspacePackages.has(name)) { if (externalWorkspacePackages.has(name)) {
const pkgDirMap = { "@paperclipai/server": "server" }; const pkgDirMap = { "@paperclipai/server": "server" };
const wsPkg = readPkg(pkgDirMap[name]); const wsPkg = readPkg(pkgDirMap[name]);
allDeps[name] = `^${wsPkg.version}`; allDeps[name] = wsPkg.version;
continue; continue;
} }
// Keep the more specific (pinned) version if conflict // Keep the more specific (pinned) version if conflict

View File

@@ -64,6 +64,11 @@ resolve_release_remote() {
return return
fi fi
if git_remote_exists public; then
printf 'public\n'
return
fi
if git_remote_exists origin; then if git_remote_exists origin; then
printf 'origin\n' printf 'origin\n'
return return
@@ -76,6 +81,18 @@ fetch_release_remote() {
git -C "$REPO_ROOT" fetch "$1" --prune --tags 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() { get_last_stable_tag() {
git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1 git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1
} }
@@ -90,32 +107,27 @@ get_current_stable_version() {
fi fi
} }
compute_bumped_version() { stable_version_for_date() {
node - "$1" "$2" <<'NODE' node - "${1:-}" <<'NODE'
const current = process.argv[2]; const input = process.argv[2];
const bump = process.argv[3];
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) { const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
throw new Error(`invalid semver version: ${current}`); if (Number.isNaN(date.getTime())) {
console.error(`invalid date: ${input}`);
process.exit(1);
} }
let [major, minor, patch] = match.slice(1).map(Number); process.stdout.write(`${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}`);
NODE
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}`); 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 NODE
} }
@@ -150,50 +162,16 @@ process.stdout.write(`${stable}-canary.${max + 1}`);
NODE NODE
} }
release_branch_name() {
printf 'release/%s\n' "$1"
}
release_notes_file() { release_notes_file() {
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1" printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
} }
default_release_worktree_path() { stable_tag_name() {
local version="$1" printf 'v%s\n' "$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() { canary_tag_name() {
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true printf 'canary/v%s\n' "$1"
}
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" ]
} }
npm_package_version_exists() { npm_package_version_exists() {
@@ -232,50 +210,38 @@ require_clean_worktree() {
fi fi
} }
git_worktree_path_for_branch() { require_on_master_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 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)" current_branch="$(git_current_branch)"
expected_branch="$(release_branch_name "$stable_version")" if [ "$current_branch" != "master" ]; then
release_fail "this release step must run from branch master, but current branch is ${current_branch:-<detached>}."
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 fi
} }
stable_release_exists_anywhere() { require_npm_publish_auth() {
local stable_version="$1" local dry_run="$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" 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() { list_public_package_info() {
stable_release_exists_anywhere "$1" "$2" 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."

476
scripts/release.sh Executable file → Normal file
View File

@@ -1,80 +1,42 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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)" REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck source=./release-lib.sh # shellcheck source=./release-lib.sh
. "$REPO_ROOT/scripts/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_PRE_FILE="$REPO_ROOT/.changeset/pre.json"
channel=""
release_date=""
dry_run=false dry_run=false
canary=false skip_verify=false
bump_type="" tag_name=""
cleanup_on_exit=false cleanup_on_exit=false
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: Usage:
./scripts/release.sh <patch|minor|major> [--canary] [--dry-run] ./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify]
Examples: Examples:
./scripts/release.sh patch ./scripts/release.sh canary
./scripts/release.sh minor --dry-run ./scripts/release.sh canary --date 2026-03-17 --dry-run
./scripts/release.sh patch --canary ./scripts/release.sh stable
./scripts/release.sh minor --canary --dry-run ./scripts/release.sh stable --date 2026-03-17 --dry-run
Notes: Notes:
- Canary publishes prerelease versions like 1.2.3-canary.0 under the npm - Canary releases publish YYYY.M.D-canary.N under the npm dist-tag "canary"
dist-tag "canary". and create the git tag canary/vYYYY.M.D-canary.N.
- Stable publishes 1.2.3 under the npm dist-tag "latest". - Stable releases publish YYYY.M.D under the npm dist-tag "latest" and create
- Run this from branch release/X.Y.Z matching the computed target version. the git tag vYYYY.M.D.
- Dry runs leave the working tree clean. - 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 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() { 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"
@@ -91,8 +53,6 @@ restore_publish_artifacts() {
cleanup_release_state() { cleanup_release_state() {
restore_publish_artifacts 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)" tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)"
if [ -n "$tracked_changes" ]; then if [ -n "$tracked_changes" ]; then
printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do
@@ -114,260 +74,134 @@ cleanup_release_state() {
fi fi
} }
if [ "$cleanup_on_exit" = true ]; then
trap cleanup_release_state EXIT
fi
set_cleanup_trap() { set_cleanup_trap() {
cleanup_on_exit=true cleanup_on_exit=true
trap cleanup_release_state EXIT trap cleanup_release_state EXIT
} }
require_npm_publish_auth() { while [ $# -gt 0 ]; do
if [ "$dry_run" = true ]; then case "$1" in
return canary|stable)
fi 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 [ -n "$channel" ] || {
release_info " ✓ Logged in to npm as $(npm whoami)" usage
return exit 1
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
} }
PUBLISH_REMOTE="$(resolve_release_remote)" PUBLISH_REMOTE="$(resolve_release_remote)"
fetch_release_remote "$PUBLISH_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)" LAST_STABLE_TAG="$(get_last_stable_tag)"
CURRENT_STABLE_VERSION="$(get_current_stable_version)" CURRENT_STABLE_VERSION="$(get_current_stable_version)"
RELEASE_DATE="${release_date:-$(utc_date_iso)}"
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" TARGET_STABLE_VERSION="$(stable_version_for_date "$RELEASE_DATE")"
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION" TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
CURRENT_BRANCH="$(git_current_branch)" DIST_TAG="latest"
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 [ "$channel" = "canary" ]; then
require_on_master_branch
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" 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 fi
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
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
require_clean_worktree require_clean_worktree
ensure_release_branch_for_version "$TARGET_STABLE_VERSION" require_npm_publish_auth "$dry_run"
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
PUBLIC_PACKAGE_INFO="$(list_public_package_info)" PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)" 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 [ -n "$PUBLIC_PACKAGE_INFO" ] || release_fail "no public packages were found in the workspace."
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 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_info "==> Release plan" release_info "==> Release plan"
release_info " Remote: $PUBLISH_REMOTE" release_info " Remote: $PUBLISH_REMOTE"
release_info " Channel: $channel"
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}" 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 " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
release_info " Current stable version: $CURRENT_STABLE_VERSION" release_info " Current stable version: $CURRENT_STABLE_VERSION"
if [ "$canary" = true ]; then release_info " Release date (UTC): $RELEASE_DATE"
release_info " Target stable version: $TARGET_STABLE_VERSION" release_info " Target stable version: $TARGET_STABLE_VERSION"
if [ "$channel" = "canary" ]; then
release_info " Canary version: $TARGET_PUBLISH_VERSION" release_info " Canary version: $TARGET_PUBLISH_VERSION"
release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
else 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 fi
release_info "" release_info ""
release_info "==> Step 1/7: Preflight checks..." release_info "==> Step 2/7: Rewriting workspace versions..."
release_info " ✓ Working tree is clean" set_public_package_version "$TARGET_PUBLISH_VERSION"
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 " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION" release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
release_info "" release_info ""
release_info "==> Step 4/7: Building workspace artifacts..." release_info "==> Step 3/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"
@@ -378,42 +212,47 @@ done
release_info " ✓ Workspace build complete" release_info " ✓ Workspace build complete"
release_info "" 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 "$REPO_ROOT/scripts/build-npm.sh" --skip-checks
release_info " ✓ CLI bundle ready" 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 "" release_info ""
if [ "$dry_run" = true ]; then if [ "$dry_run" = true ]; then
release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..." release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..."
while IFS= read -r pkg_dir; do while IFS=$'\t' read -r pkg_dir _pkg_name _pkg_version; do
[ -z "$pkg_dir" ] && continue [ -z "$pkg_dir" ] && continue
release_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 pnpm publish --dry-run --no-git-checks --tag "$DIST_TAG" 2>&1 | tail -3
done <<< "$PUBLIC_PACKAGE_DIRS" done <<< "$VERSIONED_PACKAGE_INFO"
cd "$REPO_ROOT" release_info " [dry-run] Would create git tag $tag_name on $CURRENT_SHA"
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
else else
if [ "$canary" = true ]; then release_info "==> Step 5/7: Publishing packages to npm..."
release_info "==> Step 6/7: Publishing canary to npm..." while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
npx changeset publish [ -z "$pkg_dir" ] && continue
release_info " Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" release_info " Publishing $pkg_name@$pkg_version"
else cd "$REPO_ROOT/$pkg_dir"
release_info "==> Step 6/7: Publishing stable release to npm..." pnpm publish --no-git-checks --tag "$DIST_TAG" --access public
npx changeset publish done <<< "$VERSIONED_PACKAGE_INFO"
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest" release_info " ✓ Published all packages under dist-tag $DIST_TAG"
fi fi
release_info "" release_info ""
release_info "==> Post-publish verification: Confirming npm package availability..." 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_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}" VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
MISSING_PUBLISHED_PACKAGES="" 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 [ -z "$pkg_name" ] && continue
release_info " Checking $pkg_name@$pkg_version" release_info " Checking $pkg_name@$pkg_version"
if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then 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}" MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}"
done <<< "$VERSIONED_PACKAGE_INFO" done <<< "$VERSIONED_PACKAGE_INFO"
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then [ -z "$MISSING_PUBLISHED_PACKAGES" ] || release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES"
release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES. Inspect the changeset publish output before treating this release as good."
fi
release_info " ✓ Verified all versioned packages are available on npm" release_info " ✓ Verified all versioned packages are available on npm"
fi fi
release_info "" release_info ""
if [ "$dry_run" = true ]; then if [ "$dry_run" = true ]; then
release_info "==> Step 7/7: Cleaning up dry-run state..." release_info "==> Step 7/7: Dry run complete..."
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"
else else
release_info "==> Step 7/7: Finalizing stable release commit..." release_info "==> Step 7/7: Creating git tag..."
restore_publish_artifacts git -C "$REPO_ROOT" tag "$tag_name" "$CURRENT_SHA"
release_info " ✓ Created tag $tag_name on $CURRENT_SHA"
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"
fi fi
release_info "" release_info ""
if [ "$dry_run" = true ]; then if [ "$dry_run" = true ]; then
if [ "$canary" = true ]; then release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
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"
else else
release_info "Published stable v${TARGET_STABLE_VERSION}." if [ "$channel" = "canary" ]; then
release_info "Next steps:" release_info "Published canary ${TARGET_PUBLISH_VERSION}."
release_info " git push ${PUBLISH_REMOTE} HEAD --follow-tags" release_info "Install with: npx paperclipai@canary onboard"
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
release_info " Open a PR from ${EXPECTED_RELEASE_BRANCH} to master and merge without squash or rebase" 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 fi

View File

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