diff --git a/.github/scripts/unpublished-packages.mjs b/.github/scripts/unpublished-packages.mjs new file mode 100644 index 0000000..c7bb6e0 --- /dev/null +++ b/.github/scripts/unpublished-packages.mjs @@ -0,0 +1,115 @@ +#!/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`, `released` and `version` to $GITHUB_OUTPUT when +// running under Actions; always prints a human-readable table to stdout. +// `packages` is what this run still has to publish; `released` is every package +// at the release version, which is what the release notes should name. + +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}`, + ) +} + +// Name the release after the packages that actually ship, not after the +// umbrella manifest. The `linked` group in .changeset/config.json aligns the +// versions of the packages in a given release, but it does not release every +// member every time -- at 0.10.1, caip stayed on 0.1.0 and jwt/keys on 0.9.0. +// So one run must publish exactly one version; more than one means the working +// tree is inconsistent, and picking either would mislabel the tag and release. +const versions = [...new Set(unpublished.map((result) => result.version))] + +if (versions.length > 1) { + throw new Error( + `Refusing: the packages to publish carry ${versions.length} different versions (${versions.join(", ")}). Expected one. Either a failed publish stranded a package on an older version, or a new package joined the workspace without a changeset. Fix that package's version, or publish it by hand (see RELEASING.md), then dispatch again.`, + ) +} + +const version = versions[0] ?? "" + +// The release notes name every package at the release version, not just the +// ones this run still has to publish. After a partial failure, a second +// dispatch sees a shorter `unpublished` list, and notes built from it would +// omit the packages that the first run already shipped. +const released = results.filter((result) => result.version === version) + +// The workflow names the tag and the GitHub release `agentcommercekit@`, +// so the umbrella package has to be part of this release for that name to mean +// anything. It depends on all seven scoped packages, so any release cascades to +// it -- if it is missing here, the version came from somewhere unexpected. +if (version !== "" && !released.some((r) => r.name === "agentcommercekit")) { + throw new Error( + `Refusing: the release version is ${version}, but agentcommercekit is not at that version. The release tag would name a version the umbrella package never published.`, + ) +} + +if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + [ + `count=${unpublished.length}`, + `packages=${unpublished.map((result) => result.name).join(" ")}`, + `released=${released.map((result) => result.name).join(" ")}`, + `version=${version}`, + "", + ].join("\n"), + ) +} diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..f30dc77 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,292 @@ +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 }} + released: ${{ steps.unpublished.outputs.released }} + 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 }} + RELEASED: ${{ steps.unpublished.outputs.released }} + 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 "Packages the GitHub release will name:" + echo "" + for package in $RELEASED; 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. + # pipefail matters here: Actions runs `bash -e`, which does not set it, so + # a failing `git tag --list` piped into a successful `sort` would exit 0 + # and leave an empty baseline. Every existing tag would then look new. + - name: Record pre-existing tags + id: tags-before + run: | + set -euo pipefail + 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. + # + # Runs even when the publish step fails. On a partial failure changesets + # tags the packages that did publish and only then exits non-zero + # (changesets-cli.cjs.js: tagPublish() for successes, then ExitError(1)). + # Skipping this step would destroy those tags with the runner, and a + # second dispatch could not recreate them -- getUnpublishedPackages() + # drops packages the registry already has, so changesets never tags them + # again. The job still ends red; the tags just survive. + # + # This does NOT rescue a cancelled run. changesets calls tagPublish() only + # after the whole publish loop returns, so a process killed part way writes + # no tags at all. That leaves packages on npm with no tag, which the repair + # section of RELEASING.md covers. Prefer letting a publish finish red over + # cancelling it. + # + # The outcome guard keeps this step honest when an earlier step failed and + # /tmp/tags-before.txt was never written -- without it, `comm` dies on the + # missing file and buries the real failure. + - name: Push new tags + id: tags + if: ${{ always() && steps.tags-before.outcome == 'success' }} + 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 "No new tags were created. Check npm before you dispatch again (see RELEASING.md)." >&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: the packages in a release all + # carry the same version, so one release per package would be noise. + # + # Skipped when an earlier step failed, so a partial publish never + # announces itself as a finished release. + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + VERSION: ${{ needs.verify.outputs.version }} + PACKAGES: ${{ needs.verify.outputs.released }} + 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." + ) + # --target pins the release to the published commit. Without it, gh + # creates a missing tag at the default branch head, which can point + # the release at a tree that was never published. + gh release create "$tag" --repo "$REPO" \ + --target "$SHA" \ + --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..3ba418a --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,135 @@ +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 + + # The umbrella package depends on all seven scoped packages, so any bump + # cascades to it, and the `linked` group then aligns it with the rest of + # the release. That makes its version the release version. Note that + # `linked` is not `fixed`: packages with no changeset and no changed + # dependency keep their current version and are not published. + - 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`). + + The `linked` group in `.changeset/config.json` puts every package in this release on **v${{ steps.version.outputs.version }}**. Packages with no changeset and no changed dependency keep their current version and are not published. + + 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" diff --git a/AGENTS.md b/AGENTS.md index 186530e..47c6abb 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`). The `linked` group in `.changeset/config.json` gives every package **in a given release** the same version, but it does not release every package every time — at 0.10.1, `caip` stayed on 0.1.0 and `jwt`/`keys` on 0.9.0. 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..cf136b7 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,134 @@ +# Releasing + +Releases are driven by [changesets](https://github.com/changesets/changesets) +and run in CI. There are two buttons, in order. + +The `linked` group in `.changeset/config.json` gives every package in a release +the same version. It does not release every package every time. A package with +no changeset, and no changed dependency, keeps its current version — at 0.10.1, +`caip` stayed on 0.1.0 and `jwt`/`keys` stayed on 0.9.0. Use `fixed` instead of +`linked` if you ever want all eight to move together. + +## 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. It also +leaves the release record incomplete: `changeset publish` writes the tags to your +local clone only, and it creates no GitHub release. Finish by hand: + +```bash +git push --follow-tags +gh release create "agentcommercekit@" \ + --target "$(git rev-parse HEAD)" \ + --title "v" --generate-notes +``` + +## Repairing a half-finished release + +The publish workflow pushes the tags of every package that reached npm, even when +a later package fails, so a re-dispatch normally finishes the release. Re-dispatch +before you merge anything else: the workflow always publishes and tags from the +current `main`, so a `main` that moved in between would ship the remaining +packages from a newer tree under the same version numbers. If `main` has already +moved, repair by hand instead of re-dispatching. + +Three states still need a hand. + +**npm has every version, but tags or the GitHub release are missing.** The +workflow refuses to run again, because it sees nothing left to publish. The lost +tags lived in the runner's clone, so nothing in your own clone can push them — +recreate each one first, at the commit that was published, then push them by +name: + +```bash +version="0.12.0" +sha=$(git rev-parse "") +for pkg in agentcommercekit @agentcommercekit/vc; do # the missing ones + git tag -a "$pkg@$version" "$sha" -m "$pkg@$version" + git push origin "$pkg@$version" +done +gh release create "agentcommercekit@$version" \ + --target "$sha" --title "v$version" --generate-notes +``` + +**The run was cancelled part way.** This is the worst state, so let a publish +finish red rather than cancel it. `changeset publish` writes its tags only after +the whole publish loop returns, so a cancelled process leaves packages on npm +with no tags at all. Re-dispatch to publish the rest, then recreate the missing +tags with the commands above. + +**One package sits on a different version from the rest.** The pre-flight script +refuses the dispatch, because the packages to publish no longer share one +version. Either a failed publish stranded a package, or a new package joined the +workspace without a changeset. Publish a stranded package by hand with +`pnpm --filter "" publish`, or add a changeset for a new one and merge the +version PR. Then dispatch the workflow again. 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 "$@"