Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion .github/workflows/semantic-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<branch> 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"
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 6 additions & 1 deletion packages/tooling/task-runner/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@
"sourceRoot": "packages/tooling/task-runner/src",
"projectType": "library",
"tags": ["task-runner", "type:package", "category:dev-tools"],
"implicitDependencies": []
"implicitDependencies": [],
"targets": {
"test": {
"inputs": ["default", "^production", "{workspaceRoot}/scripts/semantic-release-native-addons.mjs"]
}
}
}
42 changes: 41 additions & 1 deletion scripts/semantic-release-native-addons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading