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