From 6354e75ebe9b40e95676894ffe2c0b5ee7ad2011 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:35:11 +0000 Subject: [PATCH 1/2] ci: only require deployment approval for the actual publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release workflow ran everything in a single job bound to the "main" environment, so opening the "Version Packages" pull request — and any push to main that had nothing to release — queued up a deployment approval. Split it in two: - A "Version" job with no environment, running changesets/action without a publish-script so it can only ever open/update the pull request. It runs on ubuntu-latest and skips the Android/Rust/cpp toolchain setup, none of which versioning needs. - A "Publish" job that keeps `environment: main` and the full toolchain, and only runs when there are no pending changesets and the registry is actually missing one of our package versions. The registry check is a new `unpublished-packages` script: it asks the same question `changeset publish` asks, so chores, docs and CI commits no longer leave a deployment waiting for approval on a no-op publish. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016jd2RhdRGxJ76QtBGnFTFJ --- .github/workflows/release.yml | 51 +++++++++++++++++++---- package.json | 1 + scripts/unpublished-packages.ts | 73 +++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) create mode 100644 scripts/unpublished-packages.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df773f43..bfda8e78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,16 +18,54 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: - release: - name: Release + # Opening (or updating) the "Version Packages" pull request touches nothing + # outside this repository, so it deliberately runs without the "main" + # environment: only the publish job below waits for a deployment approval. + # Passing no publish-script also means changesets/action can never publish + # from here — with changesets pending it opens the pull request, and without + # it exits early, leaving the decision to the publish job. + version: + name: Version + runs-on: ubuntu-latest + permissions: + contents: write # the version branch + pull-requests: write # the "Version Packages" pull request + outputs: + has-changesets: ${{ steps.changesets.outputs.has-changesets }} + unpublished: ${{ steps.unpublished.outputs.unpublished }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: lts/krypton + cache: pnpm + - run: pnpm install + + - name: Create Release Pull Request + id: changesets + uses: changesets/action@v2 + + # Merging the "Version Packages" pull request is not the only way to land + # a commit without changesets — chores, docs and CI changes do it too, and + # those must not queue up a deployment approval for a no-op publish. Ask + # the registry whether anything is actually missing from it instead. + - name: Check for unpublished package versions + id: unpublished + if: steps.changesets.outputs.has-changesets == 'false' + run: pnpm run unpublished-packages + + publish: + name: Publish + needs: version + if: needs.version.outputs.has-changesets == 'false' && needs.version.outputs.unpublished == 'true' runs-on: macos-latest environment: main # Publishing to NPM happens through trusted publishing, which needs an OIDC # token. Declaring permissions at all narrows them to exactly what is listed, - # so the two the changesets action already relied on are spelled out too. + # so the one the changesets action already relied on is spelled out too. permissions: - contents: write # version commits, git tags and GitHub releases - pull-requests: write # the "Version Packages" pull request + contents: write # git tags and GitHub releases id-token: write # NPM trusted publishing steps: - uses: actions/checkout@v4 @@ -56,8 +94,7 @@ jobs: - run: rustup target add x86_64-linux-android aarch64-linux-android armv7-linux-androideabi i686-linux-android aarch64-apple-ios-sim - run: pnpm install - - name: Create Release Pull Request or Publish to NPM - id: changesets + - name: Publish to NPM uses: changesets/action@v2 with: publish-script: pnpm run release diff --git a/package.json b/package.json index 55bec8a5..d54957a5 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test": "pnpm --filter react-native-node-api --filter cmake-rn --filter gyp-to-cmake run test", "bootstrap": "node --run build && pnpm --recursive run bootstrap", "changeset": "changeset", + "unpublished-packages": "node scripts/unpublished-packages.ts", "release": "node --run prerelease && changeset publish", "prerelease": "node --run build && pnpm --recursive run prerelease && node --run publint", "init-macos-test-app": "node scripts/init-macos-test-app.ts" diff --git a/scripts/unpublished-packages.ts b/scripts/unpublished-packages.ts new file mode 100644 index 00000000..76fbb096 --- /dev/null +++ b/scripts/unpublished-packages.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import cp from "node:child_process"; +import fs from "node:fs"; + +console.log("Checking the registry for unpublished package versions"); + +function getWorkspaces() { + // `pnpm ls -r --depth -1 --json` lists every workspace project (including the + // private repo root, filtered out below) with `name`, `version` and `private` + // fields — the pnpm equivalent of the removed `npm query .workspace`. + const workspaces = JSON.parse( + cp.execFileSync("pnpm", ["ls", "-r", "--depth", "-1", "--json"], { + encoding: "utf8", + }), + ) as unknown; + assert(Array.isArray(workspaces)); + for (const workspace of workspaces) { + assert(typeof workspace === "object" && workspace !== null); + } + return workspaces as Record[]; +} + +/** + * Asks the registry the same question `changeset publish` asks before it + * uploads anything: does this exact version already exist? Querying the + * registry directly (instead of shelling out to `pnpm info`) keeps the npm CLI + * — and its validation of this repo's `devEngines` — out of the picture. + */ +async function isPublished(name: string, version: string) { + // Only the scope separator needs escaping: the registry serves scoped + // packages from "/@scope%2fname", not from a nested path. + const response = await fetch( + `https://registry.npmjs.org/${name.replace("/", "%2f")}/${version}`, + ); + if (response.status === 404) { + // Either the version or the entire package is missing from the registry. + return false; + } + assert( + response.ok, + `Unexpected response for ${name}@${version}: ${response.status} ${response.statusText}`, + ); + return true; +} + +const publishablePackages = getWorkspaces() + .filter((w) => !w.private) + .map(({ name, version }) => { + assert(typeof name === "string"); + assert(typeof version === "string"); + return { name, version }; + }); + +const unpublishedPackages: typeof publishablePackages = []; +for (const { name, version } of publishablePackages) { + const published = await isPublished(name, version); + console.log(`${published ? "✓" : "✗"} ${name}@${version}`); + if (!published) { + unpublishedPackages.push({ name, version }); + } +} + +const unpublished = unpublishedPackages.length > 0; +console.log( + unpublished + ? `${unpublishedPackages.length} of ${publishablePackages.length} package versions are missing from the registry` + : "Every package version is already on the registry", +); + +const { GITHUB_OUTPUT } = process.env; +if (GITHUB_OUTPUT) { + await fs.promises.appendFile(GITHUB_OUTPUT, `unpublished=${unpublished}\n`); +} From c3a82fd23597ee84882d4e6930383a5e81ce4ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:46:25 +0000 Subject: [PATCH 2/2] ci: use the changesets sub-actions to select the release mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled registry check from the previous commit with `changesets/action/select-mode`, which the action's README points to for repos on trusted publishing ("it's recommended to set up the individual sub-actions instead to tighten publish permissions"). select-mode answers 'version', 'publish' or 'none' — internally by way of `changeset publish-plan`, new in the Changesets v3 we just moved to. That is exactly the signal scripts/unpublished-packages.ts was computing by hand, so the script and its package.json entry are gone. The sub-action also gets the edge cases right: changesets that release nothing report 'none' rather than 'version', and packages needing only a git tag are part of the plan. Because publish-plan reaches the registry through `pnpm info`, the select step needs the same npm_config_force escape hatch as publishing. It stays step-scoped rather than workflow-scoped: as workflow env it would also reach `pnpm install`, where force means "recreate the lockfile". Also drops the workflow-level concurrency group. A publish waiting for its deployment approval used to hold that group, so no later push could refresh the version pull request until someone approved — the same class of blockage this change is meant to remove. The version and publish jobs carry their own groups instead, so they serialise against themselves but not each other. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016jd2RhdRGxJ76QtBGnFTFJ --- .github/workflows/release.yml | 72 ++++++++++++++++++++++---------- package.json | 1 - scripts/unpublished-packages.ts | 73 --------------------------------- 3 files changed, 50 insertions(+), 96 deletions(-) delete mode 100644 scripts/unpublished-packages.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bfda8e78..dd1414da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,24 +15,57 @@ on: branches: - main -concurrency: ${{ github.workflow }}-${{ github.ref }} +# Deliberately no workflow-level concurrency: a publish waiting for its +# deployment approval would hold the group and keep every later push from +# refreshing the "Version Packages" pull request until someone approves. The +# two jobs that must not overlap carry their own groups instead. jobs: + # changesets/action's sub-actions split the "what should happen?" decision out + # of the doing, which is what lets only the publish half sit behind the "main" + # environment. select-mode answers with 'version' (changesets are pending), + # 'publish' (no changesets and the registry is missing one of our versions) or + # 'none' — the last being every chore, docs and CI commit, which now finishes + # here instead of queuing a deployment approval for a no-op publish. + mode: + name: Select mode + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + mode: ${{ steps.select.outputs.mode }} + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: lts/krypton + cache: pnpm + # select-mode runs the locally installed @changesets/cli, so the workspace + # has to be installed before it. + - run: pnpm install + + - id: select + uses: changesets/action/select-mode@v2 + env: + # select-mode shells out to `changeset publish-plan`, which asks the + # registry which versions are missing — and that goes through + # `pnpm info`, so it needs the same escape hatch as publishing does. + # See the comment on the publish job below. + npm_config_force: true + # Opening (or updating) the "Version Packages" pull request touches nothing # outside this repository, so it deliberately runs without the "main" # environment: only the publish job below waits for a deployment approval. - # Passing no publish-script also means changesets/action can never publish - # from here — with changesets pending it opens the pull request, and without - # it exits early, leaving the decision to the publish job. version: name: Version + needs: mode + if: needs.mode.outputs.mode == 'version' runs-on: ubuntu-latest + concurrency: ${{ github.workflow }}-version-${{ github.ref }} permissions: contents: write # the version branch pull-requests: write # the "Version Packages" pull request - outputs: - has-changesets: ${{ steps.changesets.outputs.has-changesets }} - unpublished: ${{ steps.unpublished.outputs.unpublished }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -43,24 +76,17 @@ jobs: - run: pnpm install - name: Create Release Pull Request - id: changesets - uses: changesets/action@v2 - - # Merging the "Version Packages" pull request is not the only way to land - # a commit without changesets — chores, docs and CI changes do it too, and - # those must not queue up a deployment approval for a no-op publish. Ask - # the registry whether anything is actually missing from it instead. - - name: Check for unpublished package versions - id: unpublished - if: steps.changesets.outputs.has-changesets == 'false' - run: pnpm run unpublished-packages + uses: changesets/action/version@v2 publish: name: Publish - needs: version - if: needs.version.outputs.has-changesets == 'false' && needs.version.outputs.unpublished == 'true' + needs: mode + if: needs.mode.outputs.mode == 'publish' runs-on: macos-latest environment: main + concurrency: + group: ${{ github.workflow }}-publish-${{ github.ref }} + cancel-in-progress: false # never interrupt a release that is mid-flight # Publishing to NPM happens through trusted publishing, which needs an OIDC # token. Declaring permissions at all narrows them to exactly what is listed, # so the one the changesets action already relied on is spelled out too. @@ -95,9 +121,9 @@ jobs: - run: pnpm install - name: Publish to NPM - uses: changesets/action@v2 + uses: changesets/action/publish@v2 with: - publish-script: pnpm run release + script: pnpm run release env: # changeset publish detects the pnpm lockfile and correctly shells out # to `pnpm info`/`pnpm pack`/`pnpm publish` instead of npm's — but @@ -110,4 +136,6 @@ jobs: # and are unaffected. Passing force downgrades the devEngines error # to a warning; this is pnpm's own `info` implementation, so there's # no changesets- or pnpm-version bump that removes the need for it. + # Scoped to this step on purpose: as workflow-level env it would also + # reach `pnpm install`, where force means "recreate the lockfile". npm_config_force: true diff --git a/package.json b/package.json index d54957a5..55bec8a5 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "test": "pnpm --filter react-native-node-api --filter cmake-rn --filter gyp-to-cmake run test", "bootstrap": "node --run build && pnpm --recursive run bootstrap", "changeset": "changeset", - "unpublished-packages": "node scripts/unpublished-packages.ts", "release": "node --run prerelease && changeset publish", "prerelease": "node --run build && pnpm --recursive run prerelease && node --run publint", "init-macos-test-app": "node scripts/init-macos-test-app.ts" diff --git a/scripts/unpublished-packages.ts b/scripts/unpublished-packages.ts deleted file mode 100644 index 76fbb096..00000000 --- a/scripts/unpublished-packages.ts +++ /dev/null @@ -1,73 +0,0 @@ -import assert from "node:assert/strict"; -import cp from "node:child_process"; -import fs from "node:fs"; - -console.log("Checking the registry for unpublished package versions"); - -function getWorkspaces() { - // `pnpm ls -r --depth -1 --json` lists every workspace project (including the - // private repo root, filtered out below) with `name`, `version` and `private` - // fields — the pnpm equivalent of the removed `npm query .workspace`. - const workspaces = JSON.parse( - cp.execFileSync("pnpm", ["ls", "-r", "--depth", "-1", "--json"], { - encoding: "utf8", - }), - ) as unknown; - assert(Array.isArray(workspaces)); - for (const workspace of workspaces) { - assert(typeof workspace === "object" && workspace !== null); - } - return workspaces as Record[]; -} - -/** - * Asks the registry the same question `changeset publish` asks before it - * uploads anything: does this exact version already exist? Querying the - * registry directly (instead of shelling out to `pnpm info`) keeps the npm CLI - * — and its validation of this repo's `devEngines` — out of the picture. - */ -async function isPublished(name: string, version: string) { - // Only the scope separator needs escaping: the registry serves scoped - // packages from "/@scope%2fname", not from a nested path. - const response = await fetch( - `https://registry.npmjs.org/${name.replace("/", "%2f")}/${version}`, - ); - if (response.status === 404) { - // Either the version or the entire package is missing from the registry. - return false; - } - assert( - response.ok, - `Unexpected response for ${name}@${version}: ${response.status} ${response.statusText}`, - ); - return true; -} - -const publishablePackages = getWorkspaces() - .filter((w) => !w.private) - .map(({ name, version }) => { - assert(typeof name === "string"); - assert(typeof version === "string"); - return { name, version }; - }); - -const unpublishedPackages: typeof publishablePackages = []; -for (const { name, version } of publishablePackages) { - const published = await isPublished(name, version); - console.log(`${published ? "✓" : "✗"} ${name}@${version}`); - if (!published) { - unpublishedPackages.push({ name, version }); - } -} - -const unpublished = unpublishedPackages.length > 0; -console.log( - unpublished - ? `${unpublishedPackages.length} of ${publishablePackages.length} package versions are missing from the registry` - : "Every package version is already on the registry", -); - -const { GITHUB_OUTPUT } = process.env; -if (GITHUB_OUTPUT) { - await fs.promises.appendFile(GITHUB_OUTPUT, `unpublished=${unpublished}\n`); -}