chore: automate canary and stable releases
This commit is contained in:
@@ -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).
|
||||
@@ -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
10
.github/CODEOWNERS
vendored
Normal 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
94
.github/workflows/release-canary.yml
vendored
Normal 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}"
|
||||
@@ -1,38 +1,29 @@
|
||||
name: Release
|
||||
name: Release Stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel
|
||||
source_ref:
|
||||
description: Commit SHA, branch, or tag to publish as stable
|
||||
required: true
|
||||
type: choice
|
||||
default: canary
|
||||
options:
|
||||
- canary
|
||||
- stable
|
||||
bump:
|
||||
description: Semantic version bump
|
||||
required: true
|
||||
type: choice
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
type: string
|
||||
default: master
|
||||
stable_date:
|
||||
description: Stable release date in UTC (YYYY-MM-DD). Defaults to today.
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Preview the release without publishing
|
||||
description: Preview the stable release without publishing
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
group: release-stable
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: startsWith(github.ref, 'refs/heads/release/')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
@@ -43,6 +34,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -56,7 +48,7 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm -r typecheck
|
||||
@@ -67,21 +59,20 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
publish:
|
||||
if: startsWith(github.ref, 'refs/heads/release/')
|
||||
preview:
|
||||
if: inputs.dry_run
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.source_ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -95,32 +86,74 @@ jobs:
|
||||
cache: pnpm
|
||||
|
||||
- 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
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Run release script
|
||||
- name: Publish stable
|
||||
env:
|
||||
GITHUB_ACTIONS: "true"
|
||||
run: |
|
||||
args=("${{ inputs.bump }}")
|
||||
if [ "${{ inputs.channel }}" = "canary" ]; then
|
||||
args+=("--canary")
|
||||
fi
|
||||
if [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
args+=("--dry-run")
|
||||
args=(stable --skip-verify)
|
||||
if [ -n "${{ inputs.stable_date }}" ]; then
|
||||
args+=(--date "${{ inputs.stable_date }}")
|
||||
fi
|
||||
./scripts/release.sh "${args[@]}"
|
||||
|
||||
- name: Push stable release branch commit and tag
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
run: git push origin "HEAD:${GITHUB_REF_NAME}" --follow-tags
|
||||
- name: Push stable tag
|
||||
run: |
|
||||
tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Error: no stable tag points at HEAD after release." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "refs/tags/${tag}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: inputs.channel == 'stable' && !inputs.dry_run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -1,18 +1,19 @@
|
||||
# Publishing to npm
|
||||
|
||||
Low-level reference for how Paperclip packages are built for npm.
|
||||
Low-level reference for how Paperclip packages are prepared and published to npm.
|
||||
|
||||
For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This document is only about packaging internals and the scripts that produce publishable artifacts.
|
||||
For the maintainer workflow, use [doc/RELEASING.md](RELEASING.md). This document focuses on packaging internals.
|
||||
|
||||
## Current Release Entry Points
|
||||
|
||||
Use these scripts instead of older one-off publish commands:
|
||||
Use these scripts:
|
||||
|
||||
- [`scripts/release-start.sh`](../scripts/release-start.sh) to create or resume `release/X.Y.Z`
|
||||
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release
|
||||
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing the stable branch tag
|
||||
- [`scripts/release.sh`](../scripts/release.sh) for canary and stable publish flows
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after pushing a stable tag
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest`
|
||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh) for the CLI packaging build
|
||||
|
||||
Paperclip no longer uses release branches or Changesets for publishing.
|
||||
|
||||
## Why the CLI needs special packaging
|
||||
|
||||
@@ -23,7 +24,7 @@ The CLI package, `paperclipai`, imports code from workspace packages such as:
|
||||
- `@paperclipai/shared`
|
||||
- adapter packages under `packages/adapters/`
|
||||
|
||||
Those workspace references use `workspace:*` during development. npm cannot install those references directly for end users, so the release build has to transform the CLI into a publishable standalone package.
|
||||
Those workspace references are valid in development but not in a publishable npm package. The release flow rewrites versions temporarily, then builds a publishable CLI bundle.
|
||||
|
||||
## `build-npm.sh`
|
||||
|
||||
@@ -33,89 +34,107 @@ Run:
|
||||
./scripts/build-npm.sh
|
||||
```
|
||||
|
||||
This script does six things:
|
||||
This script:
|
||||
|
||||
1. Runs the forbidden token check unless `--skip-checks` is supplied
|
||||
2. Runs `pnpm -r typecheck`
|
||||
3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
|
||||
4. Verifies the bundled entrypoint with `node --check`
|
||||
5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json`
|
||||
6. Copies the repo `README.md` into `cli/README.md` for npm package metadata
|
||||
1. runs the forbidden token check unless `--skip-checks` is supplied
|
||||
2. runs `pnpm -r typecheck`
|
||||
3. bundles the CLI entrypoint with esbuild into `cli/dist/index.js`
|
||||
4. verifies the bundled entrypoint with `node --check`
|
||||
5. rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json`
|
||||
6. copies the repo `README.md` into `cli/README.md` for npm metadata
|
||||
|
||||
`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies.
|
||||
After the release script exits, the dev manifest and temporary files are restored automatically.
|
||||
|
||||
## Publishable CLI layout
|
||||
## Package discovery and versioning
|
||||
|
||||
During development, [`cli/package.json`](../cli/package.json) contains workspace references.
|
||||
|
||||
During release preparation:
|
||||
|
||||
- `cli/package.json` becomes a publishable manifest with external npm dependency ranges
|
||||
- `cli/package.dev.json` stores the development manifest temporarily
|
||||
- `cli/dist/index.js` contains the bundled CLI entrypoint
|
||||
- `cli/README.md` is copied in for npm metadata
|
||||
|
||||
After release finalization, the release script restores the development manifest and removes the temporary README copy.
|
||||
|
||||
## Package discovery
|
||||
|
||||
The release tooling scans the workspace for public packages under:
|
||||
Public packages are discovered from:
|
||||
|
||||
- `packages/`
|
||||
- `server/`
|
||||
- `cli/`
|
||||
|
||||
`ui/` remains ignored for npm publishing because it is private.
|
||||
`ui/` is ignored because it is private.
|
||||
|
||||
This matters because all public packages are versioned and published together as one release unit.
|
||||
The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which:
|
||||
|
||||
## Canary packaging model
|
||||
- finds all public packages
|
||||
- sorts them topologically by internal dependencies
|
||||
- rewrites each package version to the target release version
|
||||
- rewrites internal `workspace:*` dependency references to the exact target version
|
||||
- updates the CLI's displayed version string
|
||||
|
||||
Canaries are published as semver prereleases such as:
|
||||
Those rewrites are temporary. The working tree is restored after publish or dry-run.
|
||||
|
||||
- `1.2.3-canary.0`
|
||||
- `1.2.3-canary.1`
|
||||
## Version formats
|
||||
|
||||
They are published under the npm dist-tag `canary`.
|
||||
Paperclip uses calendar versions:
|
||||
|
||||
This means:
|
||||
- stable: `YYYY.M.D`
|
||||
- canary: `YYYY.M.D-canary.N`
|
||||
|
||||
- `npx paperclipai@canary onboard` can install them explicitly
|
||||
- `npx paperclipai onboard` continues to resolve `latest`
|
||||
- the stable changelog can stay at `releases/v1.2.3.md`
|
||||
Examples:
|
||||
|
||||
## Stable packaging model
|
||||
- stable: `2026.3.17`
|
||||
- canary: `2026.3.17-canary.2`
|
||||
|
||||
Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`.
|
||||
## Publish model
|
||||
|
||||
The stable publish flow also creates the local release commit and git tag on `release/X.Y.Z`. Pushing that branch commit/tag, creating the GitHub Release, and merging the release branch back to `master` happen afterward as separate maintainer steps.
|
||||
### Canary
|
||||
|
||||
Canaries publish under the npm dist-tag `canary`.
|
||||
|
||||
Example:
|
||||
|
||||
- `paperclipai@2026.3.17-canary.2`
|
||||
|
||||
This keeps the default install path unchanged while allowing explicit installs with:
|
||||
|
||||
```bash
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
### Stable
|
||||
|
||||
Stable publishes use the npm dist-tag `latest`.
|
||||
|
||||
Example:
|
||||
|
||||
- `paperclipai@2026.3.17`
|
||||
|
||||
Stable publishes do not create a release commit. Instead:
|
||||
|
||||
- package versions are rewritten temporarily
|
||||
- packages are published from the chosen source commit
|
||||
- git tag `vYYYY.M.D` points at that original commit
|
||||
|
||||
## Trusted publishing
|
||||
|
||||
The intended CI model is npm trusted publishing through GitHub OIDC.
|
||||
|
||||
That means:
|
||||
|
||||
- no long-lived `NPM_TOKEN` in repository secrets
|
||||
- GitHub Actions obtains short-lived publish credentials
|
||||
- trusted publisher rules are configured per workflow file
|
||||
|
||||
See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps.
|
||||
|
||||
## Rollback model
|
||||
|
||||
Rollback does not unpublish packages.
|
||||
Rollback does not unpublish anything.
|
||||
|
||||
Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with:
|
||||
It repoints the `latest` dist-tag to a prior stable version:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh <stable-version>
|
||||
./scripts/rollback-latest.sh 2026.3.16
|
||||
```
|
||||
|
||||
That keeps history intact while restoring the default install path quickly.
|
||||
|
||||
## Notes for CI
|
||||
|
||||
The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
|
||||
Recommended CI release setup:
|
||||
|
||||
- use npm trusted publishing via GitHub OIDC
|
||||
- require approval through the `npm-release` environment
|
||||
- run releases from `release/X.Y.Z`
|
||||
- use canary first, then stable
|
||||
This is the fastest way to restore the default install path if a stable release is bad.
|
||||
|
||||
## Related Files
|
||||
|
||||
- [`scripts/build-npm.sh`](../scripts/build-npm.sh)
|
||||
- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs)
|
||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||
- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs)
|
||||
- [`doc/RELEASING.md`](RELEASING.md)
|
||||
|
||||
271
doc/RELEASE-AUTOMATION-SETUP.md
Normal file
271
doc/RELEASE-AUTOMATION-SETUP.md
Normal 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)
|
||||
444
doc/RELEASING.md
444
doc/RELEASING.md
@@ -1,74 +1,66 @@
|
||||
# Releasing Paperclip
|
||||
|
||||
Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface.
|
||||
Maintainer runbook for shipping Paperclip across npm, GitHub, and the website-facing changelog surface.
|
||||
|
||||
The release model is branch-driven:
|
||||
The release model is now commit-driven:
|
||||
|
||||
1. Start a release train on `release/X.Y.Z`
|
||||
2. Draft the stable changelog on that branch
|
||||
3. Publish one or more canaries from that branch
|
||||
4. Publish stable from that same branch head
|
||||
5. Push the branch commit and tag
|
||||
6. Create the GitHub Release
|
||||
7. Merge `release/X.Y.Z` back to `master` without squash or rebase
|
||||
1. Every push to `master` publishes a canary automatically.
|
||||
2. Stable releases are manually promoted from a chosen tested commit or canary tag.
|
||||
3. Stable release notes live in `releases/vYYYY.M.D.md`.
|
||||
4. Only stable releases get GitHub Releases.
|
||||
|
||||
## Versioning Model
|
||||
|
||||
Paperclip uses calendar versions that still fit semver syntax:
|
||||
|
||||
- stable: `YYYY.M.D`
|
||||
- canary: `YYYY.M.D-canary.N`
|
||||
|
||||
Examples:
|
||||
|
||||
- stable on March 17, 2026: `2026.3.17`
|
||||
- fourth canary on March 17, 2026: `2026.3.17-canary.3`
|
||||
|
||||
Important constraints:
|
||||
|
||||
- do not use leading zeroes such as `2026.03.17`
|
||||
- do not use four numeric segments such as `2026.03.17.1`
|
||||
- the semver-safe canary form is `2026.3.17-canary.1`
|
||||
|
||||
## Release Surfaces
|
||||
|
||||
Every release has four separate surfaces:
|
||||
Every stable release has four separate surfaces:
|
||||
|
||||
1. **Verification** — the exact git SHA passes typecheck, tests, and build
|
||||
2. **npm** — `paperclipai` and public workspace packages are published
|
||||
3. **GitHub** — the stable release gets a git tag and GitHub Release
|
||||
4. **Website / announcements** — the stable changelog is published externally and announced
|
||||
|
||||
A release is done only when all four surfaces are handled.
|
||||
A stable release is done only when all four surfaces are handled.
|
||||
|
||||
Canaries only cover the first two surfaces plus an internal traceability tag.
|
||||
|
||||
## Core Invariants
|
||||
|
||||
- Canary and stable for `X.Y.Z` must come from the same `release/X.Y.Z` branch.
|
||||
- The release scripts must run from the matching `release/X.Y.Z` branch.
|
||||
- Once `vX.Y.Z` exists locally, on GitHub, or on npm, that release train is frozen.
|
||||
- Do not squash-merge or rebase-merge a release branch PR back to `master`.
|
||||
- The stable changelog is always `releases/vX.Y.Z.md`. Never create canary changelog files.
|
||||
|
||||
The reason for the merge rule is simple: the tag must keep pointing at the exact published commit. Squash or rebase breaks that property.
|
||||
- canaries publish from `master`
|
||||
- stables publish from an explicitly chosen source ref
|
||||
- tags point at the original source commit, not a generated release commit
|
||||
- stable notes are always `releases/vYYYY.M.D.md`
|
||||
- canaries never create GitHub Releases
|
||||
- canaries never require changelog generation
|
||||
|
||||
## TL;DR
|
||||
|
||||
### 1. Start the release train
|
||||
### Canary
|
||||
|
||||
Use this to compute the next version, create or resume the branch, create or resume a dedicated worktree, and push the branch to GitHub.
|
||||
Every push to `master` runs [`.github/workflows/release-canary.yml`](../.github/workflows/release-canary.yml).
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh patch
|
||||
```
|
||||
It:
|
||||
|
||||
That script:
|
||||
|
||||
- fetches the release remote and tags
|
||||
- computes the next stable version from the latest `v*` tag
|
||||
- creates or resumes `release/X.Y.Z`
|
||||
- creates or resumes a dedicated worktree
|
||||
- pushes the branch to the remote by default
|
||||
- refuses to reuse a frozen release train
|
||||
|
||||
### 2. Draft the stable changelog
|
||||
|
||||
From the release worktree:
|
||||
|
||||
```bash
|
||||
VERSION=X.Y.Z
|
||||
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
|
||||
```
|
||||
|
||||
### 3. Verify and publish a canary
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary patch
|
||||
./scripts/release.sh patch --canary --dry-run
|
||||
./scripts/release.sh patch --canary
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
- verifies the pushed commit
|
||||
- computes the canary version for the current UTC date
|
||||
- publishes under npm dist-tag `canary`
|
||||
- creates a git tag `canary/vYYYY.M.D-canary.N`
|
||||
|
||||
Users install canaries with:
|
||||
|
||||
@@ -76,145 +68,91 @@ Users install canaries with:
|
||||
npx paperclipai@canary onboard
|
||||
```
|
||||
|
||||
### 4. Publish stable
|
||||
### Stable
|
||||
|
||||
Use [`.github/workflows/release-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
|
||||
./scripts/release-preflight.sh stable patch
|
||||
./scripts/release.sh patch --dry-run
|
||||
./scripts/release.sh patch
|
||||
git push public-gh HEAD --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
./scripts/release.sh canary --dry-run
|
||||
```
|
||||
|
||||
Then open a PR from `release/X.Y.Z` to `master` and merge without squash or rebase.
|
||||
|
||||
## Release Branches
|
||||
|
||||
Paperclip uses one release branch per target stable version:
|
||||
|
||||
- `release/0.3.0`
|
||||
- `release/0.3.1`
|
||||
- `release/1.0.0`
|
||||
|
||||
Do not create separate per-canary branches like `canary/0.3.0-1`. A canary is just a prerelease snapshot of the same stable train.
|
||||
|
||||
## Script Entry Points
|
||||
|
||||
- [`scripts/release-start.sh`](../scripts/release-start.sh) — create or resume the release train branch/worktree
|
||||
- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — validate branch, version plan, git/npm state, and verification gate
|
||||
- [`scripts/release.sh`](../scripts/release.sh) — publish canary or stable from the release branch
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after pushing the tag
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable version
|
||||
|
||||
## Detailed Workflow
|
||||
|
||||
### 1. Start or resume the release train
|
||||
|
||||
Run:
|
||||
### Preview a stable locally
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh <patch|minor|major>
|
||||
./scripts/release.sh stable --dry-run
|
||||
```
|
||||
|
||||
Useful options:
|
||||
### Publish a stable locally
|
||||
|
||||
This is mainly for emergency/manual use. The normal path is the GitHub workflow.
|
||||
|
||||
```bash
|
||||
./scripts/release-start.sh patch --dry-run
|
||||
./scripts/release-start.sh minor --worktree-dir ../paperclip-release-0.4.0
|
||||
./scripts/release-start.sh patch --no-push
|
||||
./scripts/release.sh stable
|
||||
git push public-gh refs/tags/vYYYY.M.D
|
||||
./scripts/create-github-release.sh YYYY.M.D
|
||||
```
|
||||
|
||||
The script is intentionally idempotent:
|
||||
## Stable Changelog Workflow
|
||||
|
||||
- if `release/X.Y.Z` already exists locally, it reuses it
|
||||
- if the branch already exists on the remote, it resumes it locally
|
||||
- if the branch is already checked out in another worktree, it points you there
|
||||
- if `vX.Y.Z` already exists locally, remotely, or on npm, it refuses to reuse that train
|
||||
Stable changelog files live at:
|
||||
|
||||
### 2. Write the stable changelog early
|
||||
- `releases/vYYYY.M.D.md`
|
||||
|
||||
Create or update:
|
||||
Canaries do not get changelog files.
|
||||
|
||||
- `releases/vX.Y.Z.md`
|
||||
|
||||
That file is for the eventual stable release. It should not include `-canary` in the filename or heading.
|
||||
|
||||
Recommended structure:
|
||||
|
||||
- `Breaking Changes` when needed
|
||||
- `Highlights`
|
||||
- `Improvements`
|
||||
- `Fixes`
|
||||
- `Upgrade Guide` when needed
|
||||
- `Contributors` — @-mention every contributor by GitHub username (no emails)
|
||||
|
||||
Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative.
|
||||
|
||||
### 3. Run release preflight
|
||||
|
||||
From the `release/X.Y.Z` worktree:
|
||||
Recommended local generation flow:
|
||||
|
||||
```bash
|
||||
./scripts/release-preflight.sh canary <patch|minor|major>
|
||||
# or
|
||||
./scripts/release-preflight.sh stable <patch|minor|major>
|
||||
VERSION=2026.3.17
|
||||
claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog."
|
||||
```
|
||||
|
||||
The preflight script now checks all of the following before it runs the verification gate:
|
||||
The repo intentionally does not run this through GitHub Actions because:
|
||||
|
||||
- the worktree is clean, including untracked files
|
||||
- the current branch matches the computed `release/X.Y.Z`
|
||||
- the release train is not frozen
|
||||
- the target version is still free on npm
|
||||
- the target tag does not already exist locally or remotely
|
||||
- whether the remote release branch already exists
|
||||
- whether `releases/vX.Y.Z.md` is present
|
||||
- canaries are too frequent
|
||||
- stable notes are the only public narrative surface that needs LLM help
|
||||
- maintainer LLM tokens should not live in Actions
|
||||
|
||||
Then it runs:
|
||||
## Smoke Testing
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 4. Publish one or more canaries
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --canary --dry-run
|
||||
./scripts/release.sh <patch|minor|major> --canary
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary`
|
||||
- `latest` is unchanged
|
||||
- no git tag is created
|
||||
- no GitHub Release is created
|
||||
- the worktree returns to clean after the script finishes
|
||||
|
||||
Guardrails:
|
||||
|
||||
- the script refuses to run from the wrong branch
|
||||
- the script refuses to publish from a frozen train
|
||||
- the canary is always derived from the next stable version
|
||||
- if the stable notes file is missing, the script warns before you forget it
|
||||
|
||||
Concrete example:
|
||||
|
||||
- if the latest stable is `0.2.7`, a patch canary targets `0.2.8-canary.0`
|
||||
- `0.2.7-canary.N` is invalid because `0.2.7` is already stable
|
||||
|
||||
### 5. Smoke test the canary
|
||||
|
||||
Run the actual install path in Docker:
|
||||
For a canary:
|
||||
|
||||
```bash
|
||||
PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
For the current stable:
|
||||
|
||||
```bash
|
||||
PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
Useful isolated variants:
|
||||
|
||||
```bash
|
||||
@@ -222,14 +160,6 @@ HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary .
|
||||
HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh
|
||||
```
|
||||
|
||||
If you want to exercise onboarding from the current committed ref instead of npm, use:
|
||||
|
||||
```bash
|
||||
./scripts/clean-onboard-ref.sh
|
||||
PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh
|
||||
./scripts/clean-onboard-ref.sh HEAD
|
||||
```
|
||||
|
||||
Minimum checks:
|
||||
|
||||
- `npx paperclipai@canary onboard` installs
|
||||
@@ -238,185 +168,59 @@ Minimum checks:
|
||||
- the UI loads
|
||||
- basic company creation and dashboard load work
|
||||
|
||||
If smoke testing fails:
|
||||
## Rollback
|
||||
|
||||
1. stop the stable release
|
||||
2. fix the issue on the same `release/X.Y.Z` branch
|
||||
3. publish another canary
|
||||
4. rerun smoke testing
|
||||
Rollback does not unpublish versions.
|
||||
|
||||
### 6. Publish stable from the same release branch
|
||||
|
||||
Once the branch head is vetted, run:
|
||||
It only moves the `latest` dist-tag back to a previous stable:
|
||||
|
||||
```bash
|
||||
./scripts/release.sh <patch|minor|major> --dry-run
|
||||
./scripts/release.sh <patch|minor|major>
|
||||
./scripts/rollback-latest.sh 2026.3.16 --dry-run
|
||||
./scripts/rollback-latest.sh 2026.3.16
|
||||
```
|
||||
|
||||
Stable publish:
|
||||
|
||||
- publishes `X.Y.Z` to npm under `latest`
|
||||
- creates the local release commit
|
||||
- creates the local tag `vX.Y.Z`
|
||||
|
||||
Stable publish refuses to proceed if:
|
||||
|
||||
- the current branch is not `release/X.Y.Z`
|
||||
- the remote release branch does not exist yet
|
||||
- the stable notes file is missing
|
||||
- the target tag already exists locally or remotely
|
||||
- the stable version already exists on npm
|
||||
|
||||
Those checks intentionally freeze the train after stable publish.
|
||||
|
||||
### 7. Push the stable branch commit and tag
|
||||
|
||||
After stable publish succeeds:
|
||||
|
||||
```bash
|
||||
git push public-gh HEAD --follow-tags
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
The GitHub Release notes come from:
|
||||
|
||||
- `releases/vX.Y.Z.md`
|
||||
|
||||
### 8. Merge the release branch back to `master`
|
||||
|
||||
Open a PR:
|
||||
|
||||
- base: `master`
|
||||
- head: `release/X.Y.Z`
|
||||
|
||||
Merge rule:
|
||||
|
||||
- allowed: merge commit or fast-forward
|
||||
- forbidden: squash merge
|
||||
- forbidden: rebase merge
|
||||
|
||||
Post-merge verification:
|
||||
|
||||
```bash
|
||||
git fetch public-gh --tags
|
||||
git merge-base --is-ancestor "vX.Y.Z" "public-gh/master"
|
||||
```
|
||||
|
||||
That command must succeed. If it fails, the published tagged commit is not reachable from `master`, which means the merge strategy was wrong.
|
||||
|
||||
### 9. Finish the external surfaces
|
||||
|
||||
After GitHub is correct:
|
||||
|
||||
- publish the changelog on the website
|
||||
- write and send the announcement copy
|
||||
- ensure public docs and install guidance point to the stable version
|
||||
|
||||
## GitHub Actions Release
|
||||
|
||||
There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml).
|
||||
|
||||
Use it from the Actions tab on the relevant `release/X.Y.Z` branch:
|
||||
|
||||
1. Choose `Release`
|
||||
2. Choose `channel`: `canary` or `stable`
|
||||
3. Choose `bump`: `patch`, `minor`, or `major`
|
||||
4. Choose whether this is a `dry_run`
|
||||
5. Run it from the release branch, not from `master`
|
||||
|
||||
The workflow:
|
||||
|
||||
- reruns `typecheck`, `test:run`, and `build`
|
||||
- gates publish behind the `npm-release` environment
|
||||
- can publish canaries without touching `latest`
|
||||
- can publish stable, push the stable branch commit and tag, and create the GitHub Release
|
||||
|
||||
It does not merge the release branch back to `master` for you.
|
||||
|
||||
## Release Checklist
|
||||
|
||||
### Before any publish
|
||||
|
||||
- [ ] The release train exists on `release/X.Y.Z`
|
||||
- [ ] The working tree is clean, including untracked files
|
||||
- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the train is cut
|
||||
- [ ] The required verification gate passed on the exact branch head you want to publish
|
||||
- [ ] The bump type is correct for the user-visible impact
|
||||
- [ ] The stable changelog file exists or is ready at `releases/vX.Y.Z.md`
|
||||
- [ ] You know which previous stable version you would roll back to if needed
|
||||
|
||||
### Before a stable
|
||||
|
||||
- [ ] The candidate has already passed smoke testing
|
||||
- [ ] The remote `release/X.Y.Z` branch exists
|
||||
- [ ] You are ready to push the stable branch commit and tag immediately after npm publish
|
||||
- [ ] You are ready to create the GitHub Release immediately after the push
|
||||
- [ ] You are ready to open the PR back to `master`
|
||||
|
||||
### After a stable
|
||||
|
||||
- [ ] `npm view paperclipai@latest version` matches the new stable version
|
||||
- [ ] The git tag exists on GitHub
|
||||
- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md`
|
||||
- [ ] `vX.Y.Z` is reachable from `master`
|
||||
- [ ] The website changelog is updated
|
||||
- [ ] Announcement copy matches the stable release, not the canary
|
||||
Then fix forward with a new stable release date.
|
||||
|
||||
## Failure Playbooks
|
||||
|
||||
### If the canary publishes but the smoke test fails
|
||||
### If the canary publishes but smoke testing fails
|
||||
|
||||
Do not publish stable.
|
||||
Do not run stable.
|
||||
|
||||
Instead:
|
||||
|
||||
1. fix the issue on `release/X.Y.Z`
|
||||
2. publish another canary
|
||||
3. rerun smoke testing
|
||||
1. fix the issue on `master`
|
||||
2. merge the fix
|
||||
3. wait for the next automatic canary
|
||||
4. rerun smoke testing
|
||||
|
||||
### If stable npm publish succeeds but push or GitHub release creation fails
|
||||
### If stable npm publish succeeds but tag push or GitHub release creation fails
|
||||
|
||||
This is a partial release. npm is already live.
|
||||
|
||||
Do this immediately:
|
||||
|
||||
1. fix the git or GitHub issue from the same checkout
|
||||
2. push the stable branch commit and tag
|
||||
3. create the GitHub Release
|
||||
1. push the missing tag
|
||||
2. rerun `./scripts/create-github-release.sh YYYY.M.D`
|
||||
3. verify the GitHub Release notes point at `releases/vYYYY.M.D.md`
|
||||
|
||||
Do not republish the same version.
|
||||
|
||||
### If `latest` is broken after stable publish
|
||||
|
||||
Preview:
|
||||
Roll back the dist-tag:
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh X.Y.Z --dry-run
|
||||
./scripts/rollback-latest.sh YYYY.M.D
|
||||
```
|
||||
|
||||
Roll back:
|
||||
Then fix forward with a new stable release.
|
||||
|
||||
```bash
|
||||
./scripts/rollback-latest.sh X.Y.Z
|
||||
```
|
||||
## Related Files
|
||||
|
||||
This does not unpublish anything. It only moves the `latest` dist-tag back to the last good stable release.
|
||||
|
||||
Then fix forward with a new patch release.
|
||||
|
||||
### If the GitHub Release notes are wrong
|
||||
|
||||
Re-run:
|
||||
|
||||
```bash
|
||||
./scripts/create-github-release.sh X.Y.Z
|
||||
```
|
||||
|
||||
If the release already exists, the script updates it.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals
|
||||
- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow
|
||||
- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow
|
||||
- [`scripts/release.sh`](../scripts/release.sh)
|
||||
- [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs)
|
||||
- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh)
|
||||
- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh)
|
||||
- [`doc/PUBLISHING.md`](PUBLISHING.md)
|
||||
- [`doc/RELEASE-AUTOMATION-SETUP.md`](RELEASE-AUTOMATION-SETUP.md)
|
||||
|
||||
494
doc/plans/2026-03-17-release-automation-and-versioning.md
Normal file
494
doc/plans/2026-03-17-release-automation-and-versioning.md
Normal 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
|
||||
@@ -18,13 +18,11 @@
|
||||
"db:backup": "./scripts/backup-db.sh",
|
||||
"paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts",
|
||||
"build:npm": "./scripts/build-npm.sh",
|
||||
"release:start": "./scripts/release-start.sh",
|
||||
"release": "./scripts/release.sh",
|
||||
"release:preflight": "./scripts/release-preflight.sh",
|
||||
"release:canary": "./scripts/release.sh canary",
|
||||
"release:stable": "./scripts/release.sh stable",
|
||||
"release:github": "./scripts/create-github-release.sh",
|
||||
"release:rollback": "./scripts/rollback-latest.sh",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||
"docs:dev": "cd docs && npx mintlify dev",
|
||||
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
|
||||
@@ -34,7 +32,6 @@
|
||||
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"esbuild": "^0.27.3",
|
||||
|
||||
@@ -14,11 +14,11 @@ Usage:
|
||||
./scripts/create-github-release.sh <version> [--dry-run]
|
||||
|
||||
Examples:
|
||||
./scripts/create-github-release.sh 1.2.3
|
||||
./scripts/create-github-release.sh 1.2.3 --dry-run
|
||||
./scripts/create-github-release.sh 2026.3.17
|
||||
./scripts/create-github-release.sh 2026.3.17 --dry-run
|
||||
|
||||
Notes:
|
||||
- Run this after pushing the stable release branch and tag.
|
||||
- Run this after pushing the stable tag.
|
||||
- Defaults to git remote public-gh.
|
||||
- If the release already exists, this script updates its title and notes.
|
||||
EOF
|
||||
@@ -48,7 +48,7 @@ if [ -z "$version" ]; then
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: version must be a stable semver like 1.2.3." >&2
|
||||
echo "Error: version must be a stable calendar version like 2026.3.17." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const workspacePaths = [
|
||||
];
|
||||
|
||||
// Workspace packages that are NOT bundled and must stay as npm dependencies.
|
||||
// These get published separately via Changesets and resolved at runtime.
|
||||
// These get published separately and resolved at runtime.
|
||||
const externalWorkspacePackages = new Set([
|
||||
"@paperclipai/server",
|
||||
]);
|
||||
@@ -57,7 +57,7 @@ for (const pkgPath of workspacePaths) {
|
||||
if (externalWorkspacePackages.has(name)) {
|
||||
const pkgDirMap = { "@paperclipai/server": "server" };
|
||||
const wsPkg = readPkg(pkgDirMap[name]);
|
||||
allDeps[name] = `^${wsPkg.version}`;
|
||||
allDeps[name] = wsPkg.version;
|
||||
continue;
|
||||
}
|
||||
// Keep the more specific (pinned) version if conflict
|
||||
|
||||
@@ -64,6 +64,11 @@ resolve_release_remote() {
|
||||
return
|
||||
fi
|
||||
|
||||
if git_remote_exists public; then
|
||||
printf 'public\n'
|
||||
return
|
||||
fi
|
||||
|
||||
if git_remote_exists origin; then
|
||||
printf 'origin\n'
|
||||
return
|
||||
@@ -76,6 +81,18 @@ fetch_release_remote() {
|
||||
git -C "$REPO_ROOT" fetch "$1" --prune --tags
|
||||
}
|
||||
|
||||
git_current_branch() {
|
||||
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
|
||||
}
|
||||
|
||||
git_local_tag_exists() {
|
||||
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
|
||||
}
|
||||
|
||||
git_remote_tag_exists() {
|
||||
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
get_last_stable_tag() {
|
||||
git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1
|
||||
}
|
||||
@@ -90,32 +107,27 @@ get_current_stable_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
compute_bumped_version() {
|
||||
node - "$1" "$2" <<'NODE'
|
||||
const current = process.argv[2];
|
||||
const bump = process.argv[3];
|
||||
const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
stable_version_for_date() {
|
||||
node - "${1:-}" <<'NODE'
|
||||
const input = process.argv[2];
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`invalid semver version: ${current}`);
|
||||
const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.error(`invalid date: ${input}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let [major, minor, patch] = match.slice(1).map(Number);
|
||||
|
||||
if (bump === 'patch') {
|
||||
patch += 1;
|
||||
} else if (bump === 'minor') {
|
||||
minor += 1;
|
||||
patch = 0;
|
||||
} else if (bump === 'major') {
|
||||
major += 1;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
} else {
|
||||
throw new Error(`unsupported bump type: ${bump}`);
|
||||
process.stdout.write(`${date.getUTCFullYear()}.${date.getUTCMonth() + 1}.${date.getUTCDate()}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
process.stdout.write(`${major}.${minor}.${patch}`);
|
||||
utc_date_iso() {
|
||||
node <<'NODE'
|
||||
const date = new Date();
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
process.stdout.write(`${y}-${m}-${d}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
@@ -150,50 +162,16 @@ process.stdout.write(`${stable}-canary.${max + 1}`);
|
||||
NODE
|
||||
}
|
||||
|
||||
release_branch_name() {
|
||||
printf 'release/%s\n' "$1"
|
||||
}
|
||||
|
||||
release_notes_file() {
|
||||
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
|
||||
}
|
||||
|
||||
default_release_worktree_path() {
|
||||
local version="$1"
|
||||
local parent_dir
|
||||
local repo_name
|
||||
|
||||
parent_dir="$(cd "$REPO_ROOT/.." && pwd)"
|
||||
repo_name="$(basename "$REPO_ROOT")"
|
||||
printf '%s/%s-release-%s\n' "$parent_dir" "$repo_name" "$version"
|
||||
stable_tag_name() {
|
||||
printf 'v%s\n' "$1"
|
||||
}
|
||||
|
||||
git_current_branch() {
|
||||
git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null || true
|
||||
}
|
||||
|
||||
git_local_branch_exists() {
|
||||
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$1"
|
||||
}
|
||||
|
||||
git_remote_branch_exists() {
|
||||
git -C "$REPO_ROOT" ls-remote --exit-code --heads "$2" "refs/heads/$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
git_local_tag_exists() {
|
||||
git -C "$REPO_ROOT" show-ref --verify --quiet "refs/tags/$1"
|
||||
}
|
||||
|
||||
git_remote_tag_exists() {
|
||||
git -C "$REPO_ROOT" ls-remote --exit-code --tags "$2" "refs/tags/$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
npm_version_exists() {
|
||||
local version="$1"
|
||||
local resolved
|
||||
|
||||
resolved="$(npm view "paperclipai@${version}" version 2>/dev/null || true)"
|
||||
[ "$resolved" = "$version" ]
|
||||
canary_tag_name() {
|
||||
printf 'canary/v%s\n' "$1"
|
||||
}
|
||||
|
||||
npm_package_version_exists() {
|
||||
@@ -232,50 +210,38 @@ require_clean_worktree() {
|
||||
fi
|
||||
}
|
||||
|
||||
git_worktree_path_for_branch() {
|
||||
local branch_ref="refs/heads/$1"
|
||||
|
||||
git -C "$REPO_ROOT" worktree list --porcelain | awk -v branch_ref="$branch_ref" '
|
||||
$1 == "worktree" { path = substr($0, 10) }
|
||||
$1 == "branch" && $2 == branch_ref { print path; exit }
|
||||
'
|
||||
}
|
||||
|
||||
path_is_worktree_for_branch() {
|
||||
local path="$1"
|
||||
local branch="$2"
|
||||
require_on_master_branch() {
|
||||
local current_branch
|
||||
|
||||
[ -d "$path" ] || return 1
|
||||
current_branch="$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
[ "$current_branch" = "$branch" ]
|
||||
}
|
||||
|
||||
ensure_release_branch_for_version() {
|
||||
local stable_version="$1"
|
||||
local current_branch
|
||||
local expected_branch
|
||||
|
||||
current_branch="$(git_current_branch)"
|
||||
expected_branch="$(release_branch_name "$stable_version")"
|
||||
|
||||
if [ -z "$current_branch" ]; then
|
||||
release_fail "release work must run from branch $expected_branch, but HEAD is detached."
|
||||
fi
|
||||
|
||||
if [ "$current_branch" != "$expected_branch" ]; then
|
||||
release_fail "release work must run from branch $expected_branch, but current branch is $current_branch."
|
||||
if [ "$current_branch" != "master" ]; then
|
||||
release_fail "this release step must run from branch master, but current branch is ${current_branch:-<detached>}."
|
||||
fi
|
||||
}
|
||||
|
||||
stable_release_exists_anywhere() {
|
||||
local stable_version="$1"
|
||||
local remote="$2"
|
||||
local tag="v$stable_version"
|
||||
require_npm_publish_auth() {
|
||||
local dry_run="$1"
|
||||
|
||||
git_local_tag_exists "$tag" || git_remote_tag_exists "$tag" "$remote" || npm_version_exists "$stable_version"
|
||||
if [ "$dry_run" = true ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if npm whoami >/dev/null 2>&1; then
|
||||
release_info " ✓ Logged in to npm as $(npm whoami)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
|
||||
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
|
||||
return
|
||||
fi
|
||||
|
||||
release_fail "npm publish auth is not available. Use 'npm login' locally or run from GitHub Actions with trusted publishing."
|
||||
}
|
||||
|
||||
release_train_is_frozen() {
|
||||
stable_release_exists_anywhere "$1" "$2"
|
||||
list_public_package_info() {
|
||||
node "$REPO_ROOT/scripts/release-package-map.mjs" list
|
||||
}
|
||||
|
||||
set_public_package_version() {
|
||||
node "$REPO_ROOT/scripts/release-package-map.mjs" set-version "$1"
|
||||
}
|
||||
|
||||
168
scripts/release-package-map.mjs
Normal file
168
scripts/release-package-map.mjs
Normal 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);
|
||||
@@ -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."
|
||||
@@ -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
476
scripts/release.sh
Executable file → Normal file
@@ -1,80 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# release.sh — Prepare and publish a Paperclip release.
|
||||
#
|
||||
# Stable release:
|
||||
# ./scripts/release.sh patch
|
||||
# ./scripts/release.sh minor --dry-run
|
||||
#
|
||||
# Canary release:
|
||||
# ./scripts/release.sh patch --canary
|
||||
# ./scripts/release.sh minor --canary --dry-run
|
||||
#
|
||||
# Canary releases publish prerelease versions such as 1.2.3-canary.0 under the
|
||||
# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest".
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
# shellcheck source=./release-lib.sh
|
||||
. "$REPO_ROOT/scripts/release-lib.sh"
|
||||
CLI_DIR="$REPO_ROOT/cli"
|
||||
TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md"
|
||||
TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json"
|
||||
|
||||
channel=""
|
||||
release_date=""
|
||||
dry_run=false
|
||||
canary=false
|
||||
bump_type=""
|
||||
skip_verify=false
|
||||
tag_name=""
|
||||
|
||||
cleanup_on_exit=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/release.sh <patch|minor|major> [--canary] [--dry-run]
|
||||
./scripts/release.sh <canary|stable> [--date YYYY-MM-DD] [--dry-run] [--skip-verify]
|
||||
|
||||
Examples:
|
||||
./scripts/release.sh patch
|
||||
./scripts/release.sh minor --dry-run
|
||||
./scripts/release.sh patch --canary
|
||||
./scripts/release.sh minor --canary --dry-run
|
||||
./scripts/release.sh canary
|
||||
./scripts/release.sh canary --date 2026-03-17 --dry-run
|
||||
./scripts/release.sh stable
|
||||
./scripts/release.sh stable --date 2026-03-17 --dry-run
|
||||
|
||||
Notes:
|
||||
- Canary publishes prerelease versions like 1.2.3-canary.0 under the npm
|
||||
dist-tag "canary".
|
||||
- Stable publishes 1.2.3 under the npm dist-tag "latest".
|
||||
- Run this from branch release/X.Y.Z matching the computed target version.
|
||||
- Dry runs leave the working tree clean.
|
||||
- Canary releases publish YYYY.M.D-canary.N under the npm dist-tag "canary"
|
||||
and create the git tag canary/vYYYY.M.D-canary.N.
|
||||
- Stable releases publish YYYY.M.D under the npm dist-tag "latest" and create
|
||||
the git tag vYYYY.M.D.
|
||||
- Stable release notes must already exist at releases/vYYYY.M.D.md.
|
||||
- The script rewrites versions temporarily and restores the working tree on
|
||||
exit. Tags always point at the original source commit, not a generated
|
||||
release commit.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dry-run) dry_run=true ;;
|
||||
--canary) canary=true ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--promote)
|
||||
echo "Error: --promote was removed. Re-run a stable release from the vetted commit instead."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [ -n "$bump_type" ]; then
|
||||
echo "Error: only one bump type may be provided."
|
||||
exit 1
|
||||
fi
|
||||
bump_type="$1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
restore_publish_artifacts() {
|
||||
if [ -f "$CLI_DIR/package.dev.json" ]; then
|
||||
mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json"
|
||||
@@ -91,8 +53,6 @@ restore_publish_artifacts() {
|
||||
cleanup_release_state() {
|
||||
restore_publish_artifacts
|
||||
|
||||
rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE"
|
||||
|
||||
tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)"
|
||||
if [ -n "$tracked_changes" ]; then
|
||||
printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do
|
||||
@@ -114,260 +74,134 @@ cleanup_release_state() {
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$cleanup_on_exit" = true ]; then
|
||||
trap cleanup_release_state EXIT
|
||||
fi
|
||||
|
||||
set_cleanup_trap() {
|
||||
cleanup_on_exit=true
|
||||
trap cleanup_release_state EXIT
|
||||
}
|
||||
|
||||
require_npm_publish_auth() {
|
||||
if [ "$dry_run" = true ]; then
|
||||
return
|
||||
fi
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
canary|stable)
|
||||
if [ -n "$channel" ]; then
|
||||
release_fail "only one release channel may be provided."
|
||||
fi
|
||||
channel="$1"
|
||||
;;
|
||||
--date)
|
||||
shift
|
||||
[ $# -gt 0 ] || release_fail "--date requires YYYY-MM-DD."
|
||||
release_date="$1"
|
||||
;;
|
||||
--dry-run) dry_run=true ;;
|
||||
--skip-verify) skip_verify=true ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
release_fail "unexpected argument: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if npm whoami >/dev/null 2>&1; then
|
||||
release_info " ✓ Logged in to npm as $(npm whoami)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
|
||||
release_info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing"
|
||||
return
|
||||
fi
|
||||
|
||||
release_fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow."
|
||||
}
|
||||
|
||||
list_public_package_info() {
|
||||
node - "$REPO_ROOT" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = process.argv[2];
|
||||
const roots = ['packages', 'server', 'ui', 'cli'];
|
||||
const seen = new Set();
|
||||
const rows = [];
|
||||
|
||||
function walk(relDir) {
|
||||
const absDir = path.join(root, relDir);
|
||||
const pkgPath = path.join(absDir, 'package.json');
|
||||
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (!pkg.private) {
|
||||
rows.push([relDir, pkg.name]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
||||
walk(path.join(relDir, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of roots) {
|
||||
walk(rel);
|
||||
}
|
||||
|
||||
rows.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
for (const [dir, name] of rows) {
|
||||
const pkgPath = path.join(root, dir, 'package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
const key = `${dir}\t${name}\t${pkg.version}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
process.stdout.write(`${dir}\t${name}\t${pkg.version}\n`);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
replace_version_string() {
|
||||
local from_version="$1"
|
||||
local to_version="$2"
|
||||
|
||||
node - "$REPO_ROOT" "$from_version" "$to_version" <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = process.argv[2];
|
||||
const fromVersion = process.argv[3];
|
||||
const toVersion = process.argv[4];
|
||||
|
||||
const roots = ['packages', 'server', 'ui', 'cli'];
|
||||
const targets = new Set(['package.json', 'CHANGELOG.md']);
|
||||
const extraFiles = [path.join('cli', 'src', 'index.ts')];
|
||||
|
||||
function rewriteFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
const current = fs.readFileSync(filePath, 'utf8');
|
||||
if (!current.includes(fromVersion)) return;
|
||||
fs.writeFileSync(filePath, current.split(fromVersion).join(toVersion));
|
||||
}
|
||||
|
||||
function walk(relDir) {
|
||||
const absDir = path.join(root, relDir);
|
||||
if (!fs.existsSync(absDir)) return;
|
||||
|
||||
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
|
||||
walk(path.join(relDir, entry.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targets.has(entry.name)) {
|
||||
rewriteFile(path.join(absDir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const rel of roots) {
|
||||
walk(rel);
|
||||
}
|
||||
|
||||
for (const relFile of extraFiles) {
|
||||
rewriteFile(path.join(root, relFile));
|
||||
}
|
||||
NODE
|
||||
[ -n "$channel" ] || {
|
||||
usage
|
||||
exit 1
|
||||
}
|
||||
|
||||
PUBLISH_REMOTE="$(resolve_release_remote)"
|
||||
fetch_release_remote "$PUBLISH_REMOTE"
|
||||
|
||||
CURRENT_BRANCH="$(git_current_branch)"
|
||||
CURRENT_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)"
|
||||
LAST_STABLE_TAG="$(get_last_stable_tag)"
|
||||
CURRENT_STABLE_VERSION="$(get_current_stable_version)"
|
||||
|
||||
TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")"
|
||||
RELEASE_DATE="${release_date:-$(utc_date_iso)}"
|
||||
TARGET_STABLE_VERSION="$(stable_version_for_date "$RELEASE_DATE")"
|
||||
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
|
||||
CURRENT_BRANCH="$(git_current_branch)"
|
||||
EXPECTED_RELEASE_BRANCH="$(release_branch_name "$TARGET_STABLE_VERSION")"
|
||||
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
|
||||
RELEASE_TAG="v$TARGET_STABLE_VERSION"
|
||||
DIST_TAG="latest"
|
||||
|
||||
if [ "$canary" = true ]; then
|
||||
if [ "$channel" = "canary" ]; then
|
||||
require_on_master_branch
|
||||
TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")"
|
||||
DIST_TAG="canary"
|
||||
tag_name="$(canary_tag_name "$TARGET_PUBLISH_VERSION")"
|
||||
else
|
||||
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
|
||||
fi
|
||||
|
||||
if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then
|
||||
release_fail "next stable version matches the current stable version. Refusing to publish."
|
||||
fi
|
||||
|
||||
if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then
|
||||
release_fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N."
|
||||
fi
|
||||
NOTES_FILE="$(release_notes_file "$TARGET_STABLE_VERSION")"
|
||||
|
||||
require_clean_worktree
|
||||
ensure_release_branch_for_version "$TARGET_STABLE_VERSION"
|
||||
|
||||
if git_local_tag_exists "$RELEASE_TAG" || git_remote_tag_exists "$RELEASE_TAG" "$PUBLISH_REMOTE"; then
|
||||
release_fail "release train $EXPECTED_RELEASE_BRANCH is frozen because tag $RELEASE_TAG already exists locally or on $PUBLISH_REMOTE."
|
||||
fi
|
||||
|
||||
if npm_version_exists "$TARGET_STABLE_VERSION"; then
|
||||
release_fail "stable version $TARGET_STABLE_VERSION is already published on npm. Refusing to reuse release train $EXPECTED_RELEASE_BRANCH."
|
||||
fi
|
||||
|
||||
if [ "$canary" = false ] && [ ! -f "$NOTES_FILE" ]; then
|
||||
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
|
||||
fi
|
||||
|
||||
if [ "$canary" = true ] && [ ! -f "$NOTES_FILE" ]; then
|
||||
release_warn "stable release notes file is missing at $NOTES_FILE. Draft it before you finalize stable."
|
||||
fi
|
||||
|
||||
if ! git_remote_branch_exists "$EXPECTED_RELEASE_BRANCH" "$PUBLISH_REMOTE"; then
|
||||
if [ "$canary" = false ] && [ "$dry_run" = false ]; then
|
||||
release_fail "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE. Run ./scripts/release-start.sh $bump_type first or push the branch before stable publish."
|
||||
fi
|
||||
release_warn "remote branch $EXPECTED_RELEASE_BRANCH does not exist on $PUBLISH_REMOTE yet."
|
||||
fi
|
||||
require_npm_publish_auth "$dry_run"
|
||||
|
||||
PUBLIC_PACKAGE_INFO="$(list_public_package_info)"
|
||||
PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)"
|
||||
PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)"
|
||||
|
||||
if [ -z "$PUBLIC_PACKAGE_INFO" ]; then
|
||||
release_fail "no public packages were found in the workspace."
|
||||
[ -n "$PUBLIC_PACKAGE_INFO" ] || release_fail "no public packages were found in the workspace."
|
||||
|
||||
if [ "$channel" = "stable" ] && [ ! -f "$NOTES_FILE" ]; then
|
||||
release_fail "stable release notes file is required at $NOTES_FILE before publishing stable."
|
||||
fi
|
||||
|
||||
if [ "$channel" = "canary" ] && [ -f "$NOTES_FILE" ]; then
|
||||
release_info " ✓ Stable release notes already exist at $NOTES_FILE"
|
||||
fi
|
||||
|
||||
if git_local_tag_exists "$tag_name" || git_remote_tag_exists "$tag_name" "$PUBLISH_REMOTE"; then
|
||||
release_fail "git tag $tag_name already exists locally or on $PUBLISH_REMOTE."
|
||||
fi
|
||||
|
||||
while IFS= read -r package_name; do
|
||||
[ -z "$package_name" ] && continue
|
||||
if npm_package_version_exists "$package_name" "$TARGET_PUBLISH_VERSION"; then
|
||||
release_fail "npm version ${package_name}@${TARGET_PUBLISH_VERSION} already exists."
|
||||
fi
|
||||
done <<< "$PUBLIC_PACKAGE_NAMES"
|
||||
|
||||
release_info ""
|
||||
release_info "==> Release plan"
|
||||
release_info " Remote: $PUBLISH_REMOTE"
|
||||
release_info " Channel: $channel"
|
||||
release_info " Current branch: ${CURRENT_BRANCH:-<detached>}"
|
||||
release_info " Expected branch: $EXPECTED_RELEASE_BRANCH"
|
||||
release_info " Source commit: $CURRENT_SHA"
|
||||
release_info " Last stable tag: ${LAST_STABLE_TAG:-<none>}"
|
||||
release_info " Current stable version: $CURRENT_STABLE_VERSION"
|
||||
if [ "$canary" = true ]; then
|
||||
release_info " Target stable version: $TARGET_STABLE_VERSION"
|
||||
release_info " Release date (UTC): $RELEASE_DATE"
|
||||
release_info " Target stable version: $TARGET_STABLE_VERSION"
|
||||
if [ "$channel" = "canary" ]; then
|
||||
release_info " Canary version: $TARGET_PUBLISH_VERSION"
|
||||
release_info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N"
|
||||
else
|
||||
release_info " Stable version: $TARGET_STABLE_VERSION"
|
||||
release_info " Stable version: $TARGET_PUBLISH_VERSION"
|
||||
fi
|
||||
release_info " Dist-tag: $DIST_TAG"
|
||||
release_info " Git tag: $tag_name"
|
||||
if [ "$channel" = "stable" ]; then
|
||||
release_info " Release notes: $NOTES_FILE"
|
||||
fi
|
||||
|
||||
set_cleanup_trap
|
||||
|
||||
if [ "$skip_verify" = false ]; then
|
||||
release_info ""
|
||||
release_info "==> Step 1/7: Verification gate..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
else
|
||||
release_info ""
|
||||
release_info "==> Step 1/7: Verification gate skipped (--skip-verify)"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
release_info "==> Step 1/7: Preflight checks..."
|
||||
release_info " ✓ Working tree is clean"
|
||||
release_info " ✓ Branch matches release train"
|
||||
require_npm_publish_auth
|
||||
|
||||
if [ "$dry_run" = true ] || [ "$canary" = true ]; then
|
||||
set_cleanup_trap
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
release_info "==> Step 2/7: Creating release changeset..."
|
||||
{
|
||||
echo "---"
|
||||
while IFS= read -r pkg_name; do
|
||||
[ -z "$pkg_name" ] && continue
|
||||
echo "\"$pkg_name\": $bump_type"
|
||||
done <<< "$PUBLIC_PACKAGE_NAMES"
|
||||
echo "---"
|
||||
echo ""
|
||||
if [ "$canary" = true ]; then
|
||||
echo "Canary release preparation for $TARGET_STABLE_VERSION"
|
||||
else
|
||||
echo "Stable release preparation for $TARGET_STABLE_VERSION"
|
||||
fi
|
||||
} > "$TEMP_CHANGESET_FILE"
|
||||
release_info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages"
|
||||
|
||||
release_info ""
|
||||
release_info "==> Step 3/7: Versioning packages..."
|
||||
cd "$REPO_ROOT"
|
||||
if [ "$canary" = true ]; then
|
||||
npx changeset pre enter canary
|
||||
fi
|
||||
npx changeset version
|
||||
|
||||
if [ "$canary" = true ]; then
|
||||
BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0"
|
||||
if [ "$TARGET_PUBLISH_VERSION" != "$BASE_CANARY_VERSION" ]; then
|
||||
replace_version_string "$BASE_CANARY_VERSION" "$TARGET_PUBLISH_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
|
||||
|
||||
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
|
||||
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
|
||||
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
|
||||
fi
|
||||
release_info "==> Step 2/7: Rewriting workspace versions..."
|
||||
set_public_package_version "$TARGET_PUBLISH_VERSION"
|
||||
release_info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION"
|
||||
|
||||
release_info ""
|
||||
release_info "==> Step 4/7: Building workspace artifacts..."
|
||||
release_info "==> Step 3/7: Building workspace artifacts..."
|
||||
cd "$REPO_ROOT"
|
||||
pnpm build
|
||||
bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh"
|
||||
@@ -378,42 +212,47 @@ done
|
||||
release_info " ✓ Workspace build complete"
|
||||
|
||||
release_info ""
|
||||
release_info "==> Step 5/7: Building publishable CLI bundle..."
|
||||
release_info "==> Step 4/7: Building publishable CLI bundle..."
|
||||
"$REPO_ROOT/scripts/build-npm.sh" --skip-checks
|
||||
release_info " ✓ CLI bundle ready"
|
||||
|
||||
VERSIONED_PACKAGE_INFO="$(list_public_package_info)"
|
||||
VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")"
|
||||
if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
|
||||
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
release_info "==> Step 6/7: Previewing publish payloads (--dry-run)..."
|
||||
while IFS= read -r pkg_dir; do
|
||||
release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..."
|
||||
while IFS=$'\t' read -r pkg_dir _pkg_name _pkg_version; do
|
||||
[ -z "$pkg_dir" ] && continue
|
||||
release_info " --- $pkg_dir ---"
|
||||
cd "$REPO_ROOT/$pkg_dir"
|
||||
npm pack --dry-run 2>&1 | tail -3
|
||||
done <<< "$PUBLIC_PACKAGE_DIRS"
|
||||
cd "$REPO_ROOT"
|
||||
if [ "$canary" = true ]; then
|
||||
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary"
|
||||
else
|
||||
release_info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||
fi
|
||||
pnpm publish --dry-run --no-git-checks --tag "$DIST_TAG" 2>&1 | tail -3
|
||||
done <<< "$VERSIONED_PACKAGE_INFO"
|
||||
release_info " [dry-run] Would create git tag $tag_name on $CURRENT_SHA"
|
||||
else
|
||||
if [ "$canary" = true ]; then
|
||||
release_info "==> Step 6/7: Publishing canary to npm..."
|
||||
npx changeset publish
|
||||
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary"
|
||||
else
|
||||
release_info "==> Step 6/7: Publishing stable release to npm..."
|
||||
npx changeset publish
|
||||
release_info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest"
|
||||
fi
|
||||
release_info "==> Step 5/7: Publishing packages to npm..."
|
||||
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
|
||||
[ -z "$pkg_dir" ] && continue
|
||||
release_info " Publishing $pkg_name@$pkg_version"
|
||||
cd "$REPO_ROOT/$pkg_dir"
|
||||
pnpm publish --no-git-checks --tag "$DIST_TAG" --access public
|
||||
done <<< "$VERSIONED_PACKAGE_INFO"
|
||||
release_info " ✓ Published all packages under dist-tag $DIST_TAG"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
release_info "==> Post-publish verification: Confirming npm package availability..."
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
release_info "==> Step 6/7: Skipping npm verification in dry-run mode..."
|
||||
else
|
||||
release_info "==> Step 6/7: Confirming npm package availability..."
|
||||
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
|
||||
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
|
||||
MISSING_PUBLISHED_PACKAGES=""
|
||||
while IFS=$'\t' read -r pkg_dir pkg_name pkg_version; do
|
||||
|
||||
while IFS=$'\t' read -r _pkg_dir pkg_name pkg_version; do
|
||||
[ -z "$pkg_name" ] && continue
|
||||
release_info " Checking $pkg_name@$pkg_version"
|
||||
if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then
|
||||
@@ -427,49 +266,32 @@ else
|
||||
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}"
|
||||
done <<< "$VERSIONED_PACKAGE_INFO"
|
||||
|
||||
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
|
||||
release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES. Inspect the changeset publish output before treating this release as good."
|
||||
fi
|
||||
[ -z "$MISSING_PUBLISHED_PACKAGES" ] || release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES"
|
||||
|
||||
release_info " ✓ Verified all versioned packages are available on npm"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
release_info "==> Step 7/7: Cleaning up dry-run state..."
|
||||
release_info " ✓ Dry run leaves the working tree unchanged"
|
||||
elif [ "$canary" = true ]; then
|
||||
release_info "==> Step 7/7: Cleaning up canary state..."
|
||||
release_info " ✓ Canary state will be discarded after publish"
|
||||
release_info "==> Step 7/7: Dry run complete..."
|
||||
else
|
||||
release_info "==> Step 7/7: Finalizing stable release commit..."
|
||||
restore_publish_artifacts
|
||||
|
||||
git -C "$REPO_ROOT" add -u .changeset packages server cli
|
||||
if [ -f "$REPO_ROOT/releases/v${TARGET_STABLE_VERSION}.md" ]; then
|
||||
git -C "$REPO_ROOT" add "releases/v${TARGET_STABLE_VERSION}.md"
|
||||
fi
|
||||
|
||||
git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION"
|
||||
git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION"
|
||||
release_info " ✓ Created commit and tag v$TARGET_STABLE_VERSION"
|
||||
release_info "==> Step 7/7: Creating git tag..."
|
||||
git -C "$REPO_ROOT" tag "$tag_name" "$CURRENT_SHA"
|
||||
release_info " ✓ Created tag $tag_name on $CURRENT_SHA"
|
||||
fi
|
||||
|
||||
release_info ""
|
||||
if [ "$dry_run" = true ]; then
|
||||
if [ "$canary" = true ]; then
|
||||
release_info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}."
|
||||
else
|
||||
release_info "Dry run complete for stable v${TARGET_STABLE_VERSION}."
|
||||
fi
|
||||
elif [ "$canary" = true ]; then
|
||||
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
|
||||
release_info "Install with: npx paperclipai@canary onboard"
|
||||
release_info "Stable version remains: $CURRENT_STABLE_VERSION"
|
||||
release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
|
||||
else
|
||||
release_info "Published stable v${TARGET_STABLE_VERSION}."
|
||||
release_info "Next steps:"
|
||||
release_info " git push ${PUBLISH_REMOTE} HEAD --follow-tags"
|
||||
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
|
||||
release_info " Open a PR from ${EXPECTED_RELEASE_BRANCH} to master and merge without squash or rebase"
|
||||
if [ "$channel" = "canary" ]; then
|
||||
release_info "Published canary ${TARGET_PUBLISH_VERSION}."
|
||||
release_info "Install with: npx paperclipai@canary onboard"
|
||||
release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
|
||||
else
|
||||
release_info "Published stable ${TARGET_PUBLISH_VERSION}."
|
||||
release_info "Next steps:"
|
||||
release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
|
||||
release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -12,8 +12,8 @@ Usage:
|
||||
./scripts/rollback-latest.sh <stable-version> [--dry-run]
|
||||
|
||||
Examples:
|
||||
./scripts/rollback-latest.sh 1.2.2
|
||||
./scripts/rollback-latest.sh 1.2.2 --dry-run
|
||||
./scripts/rollback-latest.sh 2026.3.17
|
||||
./scripts/rollback-latest.sh 2026.3.17 --dry-run
|
||||
|
||||
Notes:
|
||||
- This repoints the npm dist-tag "latest" for every public package.
|
||||
@@ -45,7 +45,7 @@ if [ -z "$version" ]; then
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: version must be a stable semver like 1.2.2." >&2
|
||||
echo "Error: version must be a stable calendar version like 2026.3.17." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user