From 445a8a60711fce98af8d2ad423d661def5399ae2 Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Tue, 4 Aug 2026 18:35:43 -0400 Subject: [PATCH 1/3] ci: add changesets release and publish workflows Move the release off a laptop and into CI, in two phases: - release.yaml keeps a version-bump PR open on main as changesets land, applying `changeset version` and opening the PR under the existing GitHub App identity so it triggers the check workflow. - publish.yaml is the release button: dispatch from main, re-run the full check suite, then publish behind an approval on the `npm-publish` environment. npm auth is a granular token rather than OIDC trusted publishing: `changeset publish` shells out to `pnpm publish` in a pnpm workspace, and pnpm 11 supports neither trusted publishing nor --provenance. The token is expanded from the environment when npm reads ~/.npmrc, so it is never written to disk. Tags are created as annotated tag objects through the GitHub API, which matches the tags published so far and avoids persisting a push-capable git credential in the checkout. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/unpublished-packages.mjs | 84 ++++++++ .github/workflows/publish.yaml | 251 +++++++++++++++++++++++ .github/workflows/release.yaml | 132 ++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 .github/scripts/unpublished-packages.mjs create mode 100644 .github/workflows/publish.yaml create mode 100644 .github/workflows/release.yaml diff --git a/.github/scripts/unpublished-packages.mjs b/.github/scripts/unpublished-packages.mjs new file mode 100644 index 0000000..f83a9ea --- /dev/null +++ b/.github/scripts/unpublished-packages.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +// Lists workspace packages whose current version is not yet on the npm +// registry, i.e. exactly what `changeset publish` would push. +// +// Used by the publish workflow as a pre-flight gate: dispatching Publish with +// nothing to publish should fail loudly and early rather than end in a green +// run that shipped nothing. Queries the registry directly over HTTPS instead of +// shelling out to `npm view` 8 times -- these are public packages, so no auth is +// involved, and one fetch per package keeps the failure mode obvious. +// +// Writes `count`, `packages` and `version` to $GITHUB_OUTPUT when running under +// Actions; always prints a human-readable table to stdout. + +import { appendFileSync, readdirSync, readFileSync } from "node:fs" +import { join } from "node:path" + +const PACKAGES_DIR = "packages" +const REGISTRY = "https://registry.npmjs.org" + +function readWorkspacePackages() { + return readdirSync(PACKAGES_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(PACKAGES_DIR, entry.name, "package.json")) + .map((path) => JSON.parse(readFileSync(path, "utf8"))) + .filter((pkg) => !pkg.private && typeof pkg.name === "string") +} + +async function publishedVersions(name) { + const response = await fetch(`${REGISTRY}/${name.replace("/", "%2f")}`, { + headers: { accept: "application/vnd.npm.install-v1+json" }, + }) + + // A package that has never been published 404s. Anything else is a registry + // problem, and guessing "not published" there would let the workflow publish + // over a version that already exists. + if (response.status === 404) { + return [] + } + + if (!response.ok) { + throw new Error( + `Registry lookup for ${name} failed: ${response.status} ${response.statusText}`, + ) + } + + const body = await response.json() + return Object.keys(body.versions ?? {}) +} + +const packages = readWorkspacePackages() + +const results = await Promise.all( + packages.map(async (pkg) => ({ + name: pkg.name, + version: pkg.version, + published: (await publishedVersions(pkg.name)).includes(pkg.version), + })), +) + +const unpublished = results.filter((result) => !result.published) + +for (const result of results) { + const status = result.published ? "already on npm" : "TO PUBLISH" + console.log( + `${result.name.padEnd(32)} ${result.version.padEnd(12)} ${status}`, + ) +} + +// Every package is version-locked by the `linked` group in +// .changeset/config.json, so the umbrella package's version names the release. +const umbrella = results.find((result) => result.name === "agentcommercekit") + +if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + [ + `count=${unpublished.length}`, + `packages=${unpublished.map((result) => result.name).join(" ")}`, + `version=${umbrella?.version ?? ""}`, + "", + ].join("\n"), + ) +} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..2c3c058 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,251 @@ +name: Publish + +# Phase 2 of the two-phase changesets release: publish the versions already on +# main to npm. Phase 1 is release.yaml, which opens the version-bump PR. +# +# This is the release button: dispatch it from main after the version PR merges. +# Nothing publishes automatically on merge -- the `verify` job re-runs the full +# check suite and the `publish` job sits behind the `npm-publish` environment, +# so a human approves the actual registry write. +# +# npm auth is a granular access token (NPM_TOKEN), not OIDC trusted publishing. +# `changeset publish` shells out to `pnpm publish` in a pnpm workspace, and pnpm +# 11 supports neither trusted publishing nor --provenance (only --otp). Moving +# to OIDC would mean packing tarballs and publishing them with npm directly, +# bypassing changesets' publish path. +# +# Tags are created through the GitHub API rather than `git push`, so no +# push-capable git credential is ever persisted in the runner's checkout. + +on: + workflow_dispatch: {} + +permissions: {} + +# Never cancel in flight: `changeset publish` walks the packages one at a time +# and a cancelled run leaves a partially published release behind. +concurrency: + group: publish + cancel-in-progress: false + +jobs: + verify: + name: Verify + if: github.repository == 'agentcommercekit/ack' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + outputs: + version: ${{ steps.unpublished.outputs.version }} + count: ${{ steps.unpublished.outputs.count }} + packages: ${{ steps.unpublished.outputs.packages }} + steps: + - name: Refuse if not dispatched from main + env: + REF: ${{ github.ref }} + run: | + set -euo pipefail + if [ "$REF" != "refs/heads/main" ]; then + echo "Refusing: must dispatch from main, got $REF" >&2 + exit 1 + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - uses: ./.github/actions/setup + + # Decide what would be published BEFORE spending 20 minutes on the check + # suite. Dispatching with nothing to publish is a mistake worth surfacing + # immediately -- usually it means the version PR was never merged. + - name: Determine packages to publish + id: unpublished + run: node .github/scripts/unpublished-packages.mjs + + - name: Refuse if there is nothing to publish + env: + COUNT: ${{ steps.unpublished.outputs.count }} + run: | + set -euo pipefail + if [ "$COUNT" = "0" ]; then + echo "Refusing: every workspace package version is already on npm." >&2 + echo "Merge the version PR from the Release workflow first." >&2 + exit 1 + fi + + # Verifies the tree that is about to ship, not a cached approximation of + # it. `check` runs build + lint (including type checking) + format + test. + - name: Run full check suite + env: + ANTHROPIC_API_KEY: secret + ISSUER_PRIVATE_KEY: "0xa45f5c566918ef954e8c200a96b14092cabcd69cb8a1a132804a2b8cbb8489a1" + VERIFIER_PRIVATE_KEY: "0xeeca8f89b2f5196126f7d9199e739153bd43a13f9cdd1099e7191a33143a2059" + run: pnpm run check + + - name: Verify registry signatures of the dependency tree + run: pnpm audit signatures + + - name: Summary + env: + VERSION: ${{ steps.unpublished.outputs.version }} + COUNT: ${{ steps.unpublished.outputs.count }} + PACKAGES: ${{ steps.unpublished.outputs.packages }} + run: | + { + echo "## Verify" + echo "" + echo "- Release version: **v$VERSION**" + echo "- Packages to publish: **$COUNT**" + echo "" + for package in $PACKAGES; do + echo " - \`$package\`" + done + echo "" + echo "Awaiting approval on the \`npm-publish\` environment." + } >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Publish to npm + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 20 + # Approval gate. Configure `npm-publish` with required reviewers and limit + # its deployment branches to `main`; NPM_TOKEN lives here as an environment + # secret so no other workflow in the repo can reach it. + environment: npm-publish + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + # changeset publish tags each released package; it needs the existing + # tags to know which ones already exist. + fetch-depth: 0 + fetch-tags: true + + - uses: ./.github/actions/setup + + - name: Build packages + run: pnpm run build + + # Written literally, with no token in the file: both npm and pnpm expand + # ${NPM_TOKEN} from the environment when reading a user-level .npmrc, so + # the secret exists only in the publish step's env and never on disk. + - name: Configure npm auth + run: | + set -euo pipefail + # shellcheck disable=SC2016 # npm and pnpm expand ${NPM_TOKEN} when they + # read the file; expanding it here would write the secret to disk. + printf '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n' >> "$HOME/.npmrc" + + # changesets creates annotated tags, which need an identity to sign off. + - name: Configure git identity + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Sorted explicitly: `comm` below assumes both inputs are in the same + # collation order, which git's default refname sort does not guarantee + # across locales. + - name: Record pre-existing tags + run: git tag --list | LC_ALL=C sort > /tmp/tags-before.txt + + - name: Publish + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm exec changeset publish + + # changeset publish tags locally. Create the refs through the API rather + # than pushing, so the job never holds a push-capable git credential. + - name: Push new tags + id: tags + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + git tag --list | LC_ALL=C sort > /tmp/tags-after.txt + new_tags=$(comm -13 /tmp/tags-before.txt /tmp/tags-after.txt) + if [ -z "$new_tags" ]; then + echo "Refusing: publish created no new tags; nothing was released." >&2 + exit 1 + fi + for tag in $new_tags; do + # Singular git/ref/ is the exact-match endpoint; the plural form + # falls back to prefix matching, which would report a hit for an + # unrelated longer tag. + if gh api "repos/$REPO/git/ref/tags/$tag" >/dev/null 2>&1; then + echo "Tag $tag already exists on the remote; skipping." + continue + fi + # Create an annotated tag object rather than a lightweight ref, to + # match every tag this repo has published so far and to leave + # something a future `git verify-tag` can check. + tag_sha=$(gh api -X POST "repos/$REPO/git/tags" \ + -f "tag=$tag" \ + -f "message=$tag" \ + -f "object=$SHA" \ + -f "type=commit" \ + --jq .sha) + gh api -X POST "repos/$REPO/git/refs" \ + -f "ref=refs/tags/$tag" \ + -f "sha=$tag_sha" >/dev/null + echo "Created annotated tag $tag at $SHA" + done + { + echo "new_tags<> "$GITHUB_OUTPUT" + + # One release per version, not per package: all eight packages are + # version-locked, so eight near-identical releases would be noise. + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + VERSION: ${{ needs.verify.outputs.version }} + PACKAGES: ${{ needs.verify.outputs.packages }} + run: | + set -euo pipefail + tag="agentcommercekit@$VERSION" + if gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then + echo "Release $tag already exists; skipping." + exit 0 + fi + notes=$( + echo "Published to npm:" + echo "" + for package in $PACKAGES; do + echo "- [\`$package@$VERSION\`](https://www.npmjs.com/package/$package/v/$VERSION)" + done + echo "" + echo "See each package's \`CHANGELOG.md\` for the full set of changes." + ) + gh release create "$tag" --repo "$REPO" \ + --title "v$VERSION" \ + --notes "$notes" + + - name: Summary + env: + VERSION: ${{ needs.verify.outputs.version }} + NEW_TAGS: ${{ steps.tags.outputs.new_tags }} + run: | + { + echo "## Published" + echo "" + echo "Release **v$VERSION** is live on npm." + echo "" + echo "Tags created:" + echo "" + for tag in $NEW_TAGS; do + echo "- \`$tag\`" + done + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..573fa2f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,132 @@ +name: Release + +# Phase 1 of the two-phase changesets release: turn the changesets accumulated +# on main into a version-bump PR. Phase 2 is publish.yaml, dispatched by hand +# once this PR merges. +# +# Runs on every push to main so the release PR stays current as changesets land; +# workflow_dispatch is the "bump" button for refreshing it on demand. When no +# changesets are pending the job exits early and no PR is opened or updated. +# +# The PR is opened with a GitHub App token (ACTIONS_APP_ID / +# ACTIONS_APP_PRIVATE_KEY) rather than the default GITHUB_TOKEN so that the PR +# triggers the check workflow -- GITHUB_TOKEN-authored PRs do not -- matching +# audit-fix.yaml. The app must be installed with contents + pull-requests write. +# +# This deliberately does not use changesets/action: that action needs a +# push-capable token present from checkout onward, whereas the peter-evans flow +# below (already proven in audit-fix.yaml) lets the write-scoped App token be +# minted after dependency install, so it is never in the environment while +# third-party build scripts run. + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +# Never cancel in flight: a half-applied `changeset version` that loses its race +# to open the PR would leave the consumed changeset files unaccounted for. +concurrency: + group: release-version-pr + cancel-in-progress: false + +jobs: + version-pr: + name: Open version PR + if: github.repository == 'agentcommercekit/ack' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + # Always version main's changesets. Without an explicit ref, a manual + # workflow_dispatch from a feature branch would check out that branch + # and leak its changes into the release PR (which targets main). + ref: main + persist-credentials: false + # changeset version reads tags and history to build changelog links. + fetch-depth: 0 + + - uses: ./.github/actions/setup + + - name: Check for pending changesets + id: pending + run: | + set -euo pipefail + count=$(find .changeset -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l | tr -d ' ') + echo "count=$count" >> "$GITHUB_OUTPUT" + if [ "$count" = "0" ]; then + echo "No pending changesets; nothing to version." + else + echo "Found $count pending changeset(s)." + fi + + # @changesets/changelog-github calls the GitHub API to attribute each entry + # to its PR and author, and hard-fails without a token. + - name: Apply changesets + if: steps.pending.outputs.count != '0' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: pnpm exec changeset version + + # Every package is version-locked by the `linked` group in + # .changeset/config.json, so the umbrella package names the whole release. + - name: Read resulting version + id: version + if: steps.pending.outputs.count != '0' + run: | + set -euo pipefail + version=$(node -p "require('./packages/agentcommercekit/package.json').version") + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Minted after install so the contents/pull-requests-write token is never + # present in the runner environment while dependency build scripts execute. + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: app-token + if: steps.pending.outputs.count != '0' + with: + app-id: ${{ vars.ACTIONS_APP_ID }} + private-key: ${{ secrets.ACTIONS_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Open or update version PR + if: steps.pending.outputs.count != '0' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + branch: changeset-release/main + base: main + delete-branch: true + title: "chore(release): v${{ steps.version.outputs.version }}" + commit-message: "chore(release): v${{ steps.version.outputs.version }}" + labels: release + body: | + Version bump generated by `changeset version` from the changesets on `main` (`.github/workflows/release.yaml`). + + All packages are version-locked by the `linked` group in `.changeset/config.json`, so every published package moves to **v${{ steps.version.outputs.version }}** together. + + Review the generated `CHANGELOG.md` entries, then merge. Publishing does **not** happen on merge -- dispatch the [Publish workflow](../../actions/workflows/publish.yaml) from `main` afterwards, which requires an approval on the `npm-publish` environment. + + **AI usage:** none. Generated mechanically by `changeset version`; no AI tools authored these changes (per [AI_POLICY.md](AI_POLICY.md)). + + - name: Summary + env: + COUNT: ${{ steps.pending.outputs.count }} + VERSION: ${{ steps.version.outputs.version }} + run: | + { + echo "## Release" + echo "" + if [ "$COUNT" = "0" ]; then + echo "No pending changesets. No version PR opened." + else + echo "- Pending changesets: **$COUNT**" + echo "- Next version: **v$VERSION**" + echo "- Version PR opened/updated on \`changeset-release/main\`." + fi + } >> "$GITHUB_STEP_SUMMARY" From fa793aaf6c54955ef3aadbd865758eb959db0990 Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Tue, 4 Aug 2026 18:35:47 -0400 Subject: [PATCH 2/3] fix(bin): correct the clean script name in the release fallback `pnpm run Clean` fails with ERR_PNPM_NO_SCRIPT -- the script is `clean`. With no `set -e` the failure was swallowed and the release continued without cleaning. Fix the name, add `set -eu`, and forward arguments so a one-time password can be passed for account-level 2FA. Co-Authored-By: Claude Opus 5 (1M context) --- bin/release | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/bin/release b/bin/release index 3cfb995..a6a4e68 100755 --- a/bin/release +++ b/bin/release @@ -1,10 +1,24 @@ #!/usr/bin/env sh +# +# Local fallback for publishing packages to npm. +# +# The supported path is CI: the Release workflow opens the version PR and the +# Publish workflow ships it. See RELEASING.md. Use this only when CI cannot run. +# +# Requires an npm session with publish rights on the agentcommercekit scope. +# With account-level 2FA set to auth-and-writes, pass a one-time password: +# +# ./bin/release --otp=123456 +# + +set -eu + # Clean repository -pnpm run Clean +pnpm run clean # Build packages pnpm run build # Publish packages -pnpm exec changeset publish +pnpm exec changeset publish "$@" From cf1c22f27b55c4ec6db26428e3832d45de71cd0b Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Tue, 4 Aug 2026 18:35:52 -0400 Subject: [PATCH 3/3] docs: document the CI-driven release process Add RELEASING.md covering the two-phase changesets flow, the one-time NPM_TOKEN and environment setup, why OIDC trusted publishing is not available on this toolchain, and the local fallback. Point AGENTS.md and the contributing steps at it, and tell contributors to add a changeset. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 4 +++ CONTRIBUTING.md | 10 ++++-- RELEASING.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 RELEASING.md diff --git a/AGENTS.md b/AGENTS.md index 186530e..4cc16ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,10 @@ Adding a new type requires updating both schema files **and** the `exports` map - Exact versions enforced by `saveExact: true` in `pnpm-workspace.yaml`. - Workspace deps use `workspace:*`; shared external versions use `catalog:` (pnpm catalog in `pnpm-workspace.yaml`). +## Releasing + +Changesets, driven by CI — see [RELEASING.md](./RELEASING.md). A PR that changes published behavior needs a changeset (`pnpm exec changeset`). All eight published packages are version-locked by the `linked` group in `.changeset/config.json`, so they always share a version. Packages are on `0.x`: a breaking change is a **minor** bump, and `major` is reserved for the deliberate `1.0.0`. + ## Testing Vitest, one `vitest.config.ts` per package, `*.test.ts` co-located with source. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6387e51..436a8a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,11 +50,15 @@ follow these steps: `git checkout -b feature/your-feature-name` or `git checkout -b bugfix/issue-number`). 3. **Make your changes:** Implement your feature or bug fix. -4. **Commit your changes:** Commit your changes with a clear and descriptive +4. **Add a changeset:** If your change affects a published package, run + `pnpm exec changeset`, select the affected packages and bump type, and commit + the generated file. Write the entry as release notes for users. See + [RELEASING.md](./RELEASING.md). +5. **Commit your changes:** Commit your changes with a clear and descriptive commit message. -5. **Push to your branch:** Push your changes to your branch in your fork (e.g., +6. **Push to your branch:** Push your changes to your branch in your fork (e.g., `git push origin feature/your-feature-name`). -6. **Open a Pull Request (PR):** Open a pull request from your branch to the +7. **Open a Pull Request (PR):** Open a pull request from your branch to the `main` branch of the Agent Commerce Kit repository. - Ensure your PR description clearly describes the changes and links to any relevant issues. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..e4a46fc --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,82 @@ +# Releasing + +Releases are driven by [changesets](https://github.com/changesets/changesets) +and run in CI. There are two buttons, in order. + +All published packages are version-locked by the `linked` group in +`.changeset/config.json`, so every release moves all eight packages to the same +version. + +## 1. Land a changeset with your change + +Every PR that changes published behavior needs a changeset: + +```bash +pnpm exec changeset +``` + +Pick the affected packages and a bump type, write the entry as user-facing +release notes, and commit the generated file in `.changeset/`. + +Because the packages are on `0.x`, a breaking change is a **minor** bump, not a +major. Reserve `major` for the deliberate `1.0.0` release. + +## 2. Merge the version PR + +The [Release workflow](.github/workflows/release.yaml) runs on every push to +`main` and keeps a version-bump PR open on the `changeset-release/main` branch. +It applies `changeset version`, which consumes the changeset files, bumps every +`package.json`, and writes the `CHANGELOG.md` entries. + +Review the changelogs and merge. Nothing is published at this point. + +You can also refresh the PR on demand from the Actions tab: **Release** → **Run +workflow**. + +## 3. Press Publish + +From the Actions tab: **Publish** → **Run workflow**, dispatched from `main`. + +The workflow: + +1. Refuses to run off `main`. +2. Works out which package versions are missing from npm, and refuses to + continue if there are none (the usual cause is an unmerged version PR). +3. Runs the full `pnpm run check` suite and `pnpm audit signatures`. +4. Waits for an approval on the `npm-publish` environment. +5. Runs `changeset publish`, creates the git tags through the GitHub API, and + opens a GitHub release for the version. + +## One-time setup + +- **`NPM_TOKEN`** — a granular npm access token, stored as an **environment** + secret on `npm-publish` (not a repository secret, so no other workflow can + reach it). Scope it to read+write on `agentcommercekit` and the + `@agentcommercekit` scope. It must be an automation-class token: the account + has 2FA set to `auth-and-writes`, and a token that prompts for a one-time + password cannot work unattended. +- **`npm-publish` environment** — required reviewers, and deployment branches + limited to `main`. +- **GitHub App** — `ACTIONS_APP_ID` (variable) and `ACTIONS_APP_PRIVATE_KEY` + (secret) already exist for `audit-fix.yaml`. The Release workflow reuses them + to open the version PR under the app identity, so the PR triggers the check + workflow. + +## Why not OIDC trusted publishing + +`changeset publish` shells out to `pnpm publish` in a pnpm workspace, and pnpm 11 +supports neither npm trusted publishing nor `--provenance` — only `--otp`. +Adopting OIDC would mean packing tarballs and publishing them with `npm` +directly, bypassing changesets' publish path. + +## Manual fallback + +`./bin/release` cleans, builds, and publishes from a local checkout. It needs an +npm session with publish rights, and a one-time password because of +account-level 2FA: + +```bash +./bin/release --otp=123456 +``` + +Prefer CI. This path skips the approval gate and the verification steps.