From 6192653397500d66ba123e69e014886717ccb7fc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 09:21:29 +0100 Subject: [PATCH 1/2] ci: publish @exadev/breadboard-client to npmjs.com via OIDC trusted publishing The npmjs.com publish authenticated with a long-lived NPM_TOKEN read from an org-level Actions secret. It now exchanges the release job's OIDC identity for a short-lived registry token instead, so no npm credential is stored anywhere. The job gains id-token: write, and NODE_AUTH_TOKEN is blanked rather than dropped: setup-node exports a dummy value when none is supplied, and a blank one makes a missing or misconfigured trusted publisher fail the publish outright instead of silently falling back to token auth. Node moves from 20 to 22 on that step and npm is upgraded to latest, because trusted publishing needs npm 11.5.1 or later on Node 22.14.0 or higher and Node 22 still ships npm 10.x. registry-url and scope stay in place. The GitHub Packages publish earlier in the same job maps @exadev to npm.pkg.github.com in the same .npmrc, so remapping the scope here is what keeps this step pointed at npmjs.com. The GitHub Packages publish itself is unchanged and still uses GITHUB_TOKEN. --- .github/workflows/ci.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bd635a..bd4c9fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,7 @@ jobs: issues: write pull-requests: write packages: write + id-token: write # OIDC identity for npm trusted publishing, so the npmjs.com publish needs no NPM_TOKEN steps: - uses: actions/checkout@v4 with: @@ -100,15 +101,20 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npx semantic-release - # Publish to npmjs.com + # Publish to npmjs.com via trusted publishing, which exchanges the job's OIDC identity for a short-lived registry token instead of a long-lived NPM_TOKEN. registry-url and scope are still set, unlike in this org's single-registry repos that omit them: the GitHub Packages step above already mapped @exadev to npm.pkg.github.com in the same .npmrc, so without remapping it here `npm publish` would push to GitHub Packages a second time rather than to npmjs.com. The _authToken line setup-node writes alongside it is harmless, because npm runs the OIDC exchange before it reads any credential and writes the exchanged token over that same user-level config key. Node 22 rather than 20: trusted publishing requires npm 11.5.1 or later on Node 22.14.0 or higher, and Node 22 still ships npm 10.x, so the CLI is upgraded explicitly below. - name: Set up Node.js for npm uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" registry-url: "https://registry.npmjs.org" scope: "@exadev" + - name: Upgrade npm for OIDC trusted publishing + run: npm install -g npm@latest + - name: Publish to npm env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Blanked rather than omitted. setup-node exports a dummy NODE_AUTH_TOKEN when none is supplied, and an NPM_TOKEN inherited from a workflow-level env block would be picked up too; either would authenticate this publish as a token publish instead of failing loudly if the trusted publisher is ever missing or misconfigured. + NODE_AUTH_TOKEN: "" + NPM_TOKEN: "" run: npm publish From 23cd8d65423015e105a41e11a58babf40ec35631 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 20 Sep 2026 09:38:46 +0100 Subject: [PATCH 2/2] ci: publish to npmjs.com only when semantic-release made a release The npmjs.com publish step ran on every push to the default branch, regardless of whether semantic-release had released anything. When the commits since the last tag warrant no release, package.json keeps its current version and `npm publish` re-publishes that version, which the registry rejects as a duplicate and which fails the job. The Release step now compares package.json's version before and after semantic-release and exposes a `released` output, and the three npmjs steps are conditional on it. package.json is the signal because semantic-release exposes no output of its own, and @semantic-release/npm's prepare step writes the next version into it exactly when there is a release to make. This matches the GitHub Packages leg, which is already conditional in the same way: semantic-release only reaches its publish phase when it has something to publish. --- .github/workflows/ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd4c9fe..0684ce7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,14 +95,28 @@ jobs: - name: Build run: yarn build + # Reports whether semantic-release actually released, which gates the npmjs.com publish below. semantic-release exposes no step output of its own, so the signal is package.json's version: @semantic-release/npm's prepare step writes the next version into it on a release and leaves it untouched when the commits since the last tag warrant none. Publishing to GitHub Packages is already conditional in the same way, because semantic-release only reaches its publish phase when it has a release to make. - name: Release + id: release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx semantic-release + run: | + before=$(node -p "require('./package.json').version") + npx semantic-release + after=$(node -p "require('./package.json').version") + if [ "$before" = "$after" ]; then + echo "semantic-release made no release; version is still $after" + echo "released=false" >> "$GITHUB_OUTPUT" + else + echo "semantic-release released $after (was $before)" + echo "released=true" >> "$GITHUB_OUTPUT" + fi + echo "version=$after" >> "$GITHUB_OUTPUT" # Publish to npmjs.com via trusted publishing, which exchanges the job's OIDC identity for a short-lived registry token instead of a long-lived NPM_TOKEN. registry-url and scope are still set, unlike in this org's single-registry repos that omit them: the GitHub Packages step above already mapped @exadev to npm.pkg.github.com in the same .npmrc, so without remapping it here `npm publish` would push to GitHub Packages a second time rather than to npmjs.com. The _authToken line setup-node writes alongside it is harmless, because npm runs the OIDC exchange before it reads any credential and writes the exchanged token over that same user-level config key. Node 22 rather than 20: trusted publishing requires npm 11.5.1 or later on Node 22.14.0 or higher, and Node 22 still ships npm 10.x, so the CLI is upgraded explicitly below. - name: Set up Node.js for npm + if: steps.release.outputs.released == 'true' uses: actions/setup-node@v4 with: node-version: "22" @@ -110,9 +124,11 @@ jobs: scope: "@exadev" - name: Upgrade npm for OIDC trusted publishing + if: steps.release.outputs.released == 'true' run: npm install -g npm@latest - name: Publish to npm + if: steps.release.outputs.released == 'true' env: # Blanked rather than omitted. setup-node exports a dummy NODE_AUTH_TOKEN when none is supplied, and an NPM_TOKEN inherited from a workflow-level env block would be picked up too; either would authenticate this publish as a token publish instead of failing loudly if the trusted publisher is ever missing or misconfigured. NODE_AUTH_TOKEN: ""