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