From 1a7108db41d1273b26ee3197b70e6d9254e7ea58 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Wed, 21 Oct 2026 09:18:37 +0200 Subject: [PATCH 1/3] fix(release): skip already-published native addons when pnpm logs to stdout pnpm 11 writes its publish report to stdout and leaves stderr empty, so `error.message` - which execFileSync builds from stderr only - never contained the registry's "You cannot publish over the previously published versions" text. The guard meant to make re-runs idempotent therefore never matched, and a partially released version aborted every later release. Match against stdout and stderr as well as the message. This unblocks the stuck @visulima/task-runner release: all eight platform bindings were published at 3.0.1 on Oct 17 before the run failed at `git push --tags`, so they must be skipped for the parent package, tag and release commit to land. --- .../semantic-release-native-addons.test.ts | 61 +++++++++++++++++++ packages/tooling/task-runner/project.json | 2 +- scripts/semantic-release-native-addons.mjs | 42 ++++++++++++- 3 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 packages/tooling/task-runner/__tests__/unit/semantic-release-native-addons.test.ts diff --git a/packages/tooling/task-runner/__tests__/unit/semantic-release-native-addons.test.ts b/packages/tooling/task-runner/__tests__/unit/semantic-release-native-addons.test.ts new file mode 100644 index 00000000..793dd13e --- /dev/null +++ b/packages/tooling/task-runner/__tests__/unit/semantic-release-native-addons.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +// eslint-disable-next-line import/no-relative-packages -- workspace release script, not a package entry point +import { isAlreadyPublishedError } from "../../../../../scripts/semantic-release-native-addons.mjs"; + +const publishError = (properties: { message?: string; stderr?: string; stdout?: string }): Error => + Object.assign(new Error(properties.message ?? "Command failed: pnpm publish"), properties); + +describe(isAlreadyPublishedError, () => { + it("detects the rejection when pnpm writes it to stdout", () => { + expect.assertions(1); + + // pnpm 11 prints the registry report on stdout and leaves stderr empty, + // so the message alone never contains the E403 text. + const error = publishError({ + message: "Command failed: pnpm publish /repo/packages/pkg/npm/darwin-arm64 --tag latest --access public --no-git-checks\n", + stderr: "", + stdout: [ + "📦 @visulima/task-runner-binding-darwin-arm64@3.0.1 → https://registry.npmjs.org/", + "[E403] 403 Forbidden - PUT https://registry.npmjs.org/@visulima%2ftask-runner-binding-darwin-arm64 - You cannot publish over the previously published versions: 3.0.1.", + ].join("\n"), + }); + + expect(isAlreadyPublishedError(error)).toBe(true); + }); + + it("detects the rejection when it reaches the message", () => { + expect.assertions(1); + + const error = publishError({ + message: "Command failed: npm publish\nnpm ERR! You cannot publish over the previously published versions: 3.0.1.", + }); + + expect(isAlreadyPublishedError(error)).toBe(true); + }); + + it("detects the npm legacy conflict code", () => { + expect.assertions(1); + + expect(isAlreadyPublishedError(publishError({ message: "npm ERR! code EPUBLISHCONFLICT" }))).toBe(true); + }); + + it("does not treat an unrelated publish failure as already published", () => { + expect.assertions(1); + + const error = publishError({ + message: "Command failed: pnpm publish", + stderr: "[E401] 401 Unauthorized - PUT https://registry.npmjs.org/@visulima%2fvis - authentication token not found.", + stdout: "", + }); + + expect(isAlreadyPublishedError(error)).toBe(false); + }); + + it("handles a non-Error rejection", () => { + expect.assertions(2); + + expect(isAlreadyPublishedError("403 Forbidden: You cannot publish over the previously published versions")).toBe(true); + expect(isAlreadyPublishedError(undefined)).toBe(false); + }); +}); diff --git a/packages/tooling/task-runner/project.json b/packages/tooling/task-runner/project.json index 9b708bc1..149c400c 100644 --- a/packages/tooling/task-runner/project.json +++ b/packages/tooling/task-runner/project.json @@ -4,5 +4,5 @@ "sourceRoot": "packages/tooling/task-runner/src", "projectType": "library", "tags": ["task-runner", "type:package", "category:dev-tools"], - "implicitDependencies": [] + "implicitDependencies": ["scripts/semantic-release-native-addons.mjs"] } diff --git a/scripts/semantic-release-native-addons.mjs b/scripts/semantic-release-native-addons.mjs index 01895311..1b23d144 100644 --- a/scripts/semantic-release-native-addons.mjs +++ b/scripts/semantic-release-native-addons.mjs @@ -141,6 +141,46 @@ export const patchVersionCheck = (content, version) => { return { count: matches.length, patched: content.replace(VERSION_CHECK_PATTERN, replacement) }; }; +// A publish retried after a partial release must treat "that version is +// already on the registry" as success: the binding exists, only the parent +// package, tag and release commit still have to land. Matching on +// `error.message` alone is not enough — pnpm 11 writes its publish report to +// stdout and leaves stderr empty, so the registry's 403 text never reaches +// the message and the release aborts on a version that is impossible to +// republish. +const ALREADY_PUBLISHED_PATTERNS = ["EPUBLISHCONFLICT", "cannot publish over", "previously published versions"]; + +/** + * True when `error` is a registry "version already exists" rejection, no + * matter which stream the package manager wrote the report to. + * + * @param {unknown} error Thrown by the publish command. + * @returns {boolean} + */ +export const isAlreadyPublishedError = (error) => { + const parts = []; + + if (error instanceof Error) { + parts.push(error.message); + } else { + parts.push(String(error)); + } + + for (const stream of ["stdout", "stderr"]) { + const value = error?.[stream]; + + if (typeof value === "string") { + parts.push(value); + } else if (value) { + parts.push(String(value)); + } + } + + const text = parts.join("\n"); + + return ALREADY_PUBLISHED_PATTERNS.some((pattern) => text.includes(pattern)); +}; + export const verifyConditions = async (_pluginConfig, context) => { const { cwd, env, logger } = context; const platformDirs = await getPlatformDirs(cwd); @@ -232,7 +272,7 @@ export const prepare = async (_pluginConfig, context) => { } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (message.includes("You cannot publish over the previously published versions")) { + if (isAlreadyPublishedError(error)) { logger.warn(`${pkg.name}@${version} already published, skipping`); } else { pkg.version = originalVersion; From 1240572ff9a15e18cfe04d3220fff17fabce1e3d Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Wed, 21 Oct 2026 10:07:24 +0200 Subject: [PATCH 2/3] ci(release): recover from a rejected release push @semantic-release/git pushes HEAD: during prepare. When anything else lands on the branch between checkout and that push, git rejects it with "fetch first" and the release aborts: the failed package has no tag and no release commit, while its native bindings are already on npm. The push that broke the Oct 17 run was a lock-file maintenance commit that landed on main 39 seconds before the release tried to push. Keep the multi-semantic-release log and, on that specific failure, dispatch a fresh run from the new tip instead of leaving the branch red. Re-running is safe because npm and the native-addon plugin skip versions that already exist. --- .github/workflows/semantic-release.yml | 34 +++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml index dc37ef4a..3bd396a0 100644 --- a/.github/workflows/semantic-release.yml +++ b/.github/workflows/semantic-release.yml @@ -200,7 +200,9 @@ jobs: export GIT_CONFIG_COUNT=1 export GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" - pnpm exec multi-semantic-release --ignore-packages='packages/tooling/task-runner/npm/**,packages/terminal/tui/npm/**,packages/tooling/vis/npm/**,packages/tooling/secret-scanner/npm/**' + # Keep the log for the recovery step below: a rejected push is + # only recoverable if its text can be recognised after the fact. + pnpm exec multi-semantic-release --ignore-packages='packages/tooling/task-runner/npm/**,packages/terminal/tui/npm/**,packages/tooling/vis/npm/**,packages/tooling/secret-scanner/npm/**' 2>&1 | tee "${RUNNER_TEMP}/semantic-release.log" # multi-semantic-release pushes per-package `chore(release)` commits that # bump each package.json (and any cross-package dependency ranges), but @@ -260,3 +262,33 @@ jobs: fi echo "${BRANCH} moved to $(git rev-parse --short FETCH_HEAD) during the release; dispatching a new release run." gh workflow run semantic-release.yml --repo "$REPO" --ref "$BRANCH" + + # @semantic-release/git pushes HEAD: from inside the release. If + # anything else lands on the branch between checkout and that push (a bot + # commit, a merge), git rejects it with "fetch first" and the release + # aborts: some packages are already on npm, but the failed package has no + # tag and no release commit. Both npm and the native-addon plugin skip + # versions that already exist, so a fresh run from the new tip can finish + # the release instead of leaving the branch red until someone re-runs it. + - name: "Re-run the release after a rejected push" + if: "failure()" + shell: "bash" + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + BRANCH: "${{ github.ref_name }}" + REPO: "${{ github.repository }}" + RUN_SHA: "${{ github.sha }}" + RELEASE_LOG: "${{ runner.temp }}/semantic-release.log" + run: | + set -euo pipefail + if [ ! -f "$RELEASE_LOG" ] || ! grep -q "failed to push some refs" "$RELEASE_LOG"; then + echo "The release did not fail on a rejected push; nothing to recover." + exit 0 + fi + git fetch --quiet origin "$BRANCH" + if [ "$(git rev-parse "$RUN_SHA")" = "$(git rev-parse FETCH_HEAD)" ]; then + echo "The push was rejected but ${BRANCH} has not moved since this run started; not re-dispatching." + exit 0 + fi + echo "${BRANCH} moved to $(git rev-parse --short FETCH_HEAD) during the release; dispatching a new release run." + gh workflow run semantic-release.yml --repo "$REPO" --ref "$BRANCH" From a7d435100f8df890ad6be3e3946f951ca8383e02 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Wed, 21 Oct 2026 10:10:16 +0200 Subject: [PATCH 3/3] fix(ci): express the release-script dependency through test inputs nx implicitDependencies only accepts project names, so listing the script there failed the project graph with "point to non-existent project(s)". Attach the file to the test target's inputs instead, which is what marks task-runner affected and reruns its tests when the plugin changes. --- packages/tooling/task-runner/project.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/tooling/task-runner/project.json b/packages/tooling/task-runner/project.json index 149c400c..24f06f1b 100644 --- a/packages/tooling/task-runner/project.json +++ b/packages/tooling/task-runner/project.json @@ -4,5 +4,10 @@ "sourceRoot": "packages/tooling/task-runner/src", "projectType": "library", "tags": ["task-runner", "type:package", "category:dev-tools"], - "implicitDependencies": ["scripts/semantic-release-native-addons.mjs"] + "implicitDependencies": [], + "targets": { + "test": { + "inputs": ["default", "^production", "{workspaceRoot}/scripts/semantic-release-native-addons.mjs"] + } + } }