From 91443501fb696e93625a1e83abcaed6c393a7891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Ara=C3=BAjo?= Date: Tue, 21 Jul 2026 13:57:48 -0300 Subject: [PATCH] fix(release): make package publish reruns idempotent --- scripts/ci-release-publish.test.ts | 41 ++++++++++++++++++++++++++++ scripts/ci-release-publish.ts | 44 ++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 scripts/ci-release-publish.test.ts diff --git a/scripts/ci-release-publish.test.ts b/scripts/ci-release-publish.test.ts new file mode 100644 index 000000000..20f9c845d --- /dev/null +++ b/scripts/ci-release-publish.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { $ } from "bun"; +import { inspectPackedTarball, isVersionAlreadyPublished } from "./ci-release-publish.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("release publish", () => { + it("uses the packed manifest identity for an exact-version registry preflight", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-release-publish-test-")); + temporaryDirectories.push(root); + const packageDir = path.join(root, "package"); + await fs.mkdir(packageDir); + await Bun.write( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "@oh-my-pi/pi-test", version: "1.2.3" }), + ); + const tarball = path.join(root, "test.tgz"); + await $`tar -czf ${tarball} -C ${root} package`.quiet(); + + await expect(inspectPackedTarball(tarball)).resolves.toEqual({ + name: "@oh-my-pi/pi-test", + version: "1.2.3", + path: tarball, + }); + }); + + it("recognizes npm's existing-version machine codes without matching registry prose", () => { + expect(isVersionAlreadyPublished("npm error code E409\nnpm error Cannot publish over existing version")).toBe( + true, + ); + expect(isVersionAlreadyPublished("npm error code EPUBLISHCONFLICT")).toBe(true); + expect(isVersionAlreadyPublished("cannot publish over the previously published version")).toBe(false); + }); +}); diff --git a/scripts/ci-release-publish.ts b/scripts/ci-release-publish.ts index 78f180466..e227c34e5 100644 --- a/scripts/ci-release-publish.ts +++ b/scripts/ci-release-publish.ts @@ -227,6 +227,25 @@ export async function prepareNativeCorePackage(pkgDir: string, write: boolean): * only on the OIDC path, so we never pass `--provenance` (it would hard-fail the * token fallback). */ +export interface PackedTarball { + name: string; + version: string; + path: string; +} + +/** Read the package identity npm will publish from the packed archive. */ +export async function inspectPackedTarball(tarballPath: string): Promise { + const extracted = await $`tar -xOzf ${tarballPath} package/package.json`.quiet().nothrow(); + if (extracted.exitCode !== 0) { + throw new Error(`Could not read packed manifest from ${tarballPath}: ${extracted.stderr.toString().trim()}`); + } + const manifest = JSON.parse(extracted.stdout.toString()) as PackageManifest; + if (typeof manifest.name !== "string" || typeof manifest.version !== "string") { + throw new Error(`Packed manifest is missing name/version: ${tarballPath}`); + } + return { name: manifest.name, version: manifest.version, path: tarballPath }; +} + async function packAndPublish(dir: string, name: string): Promise { if (isDryRun) { console.log(`DRY RUN bun pm pack && npm publish --access public (${path.relative(repoRoot, dir)})`); @@ -243,15 +262,21 @@ async function packAndPublish(dir: string, name: string): Promise { } const tarball = (await fs.readdir(packDir)).find(entry => entry.endsWith(".tgz")); if (!tarball) throw new Error(`bun pm pack produced no tarball for ${name} (${path.relative(repoRoot, dir)})`); - const result = await $`npm publish ${path.join(packDir, tarball)} --access public`.quiet().nothrow(); + const packedTarball = await inspectPackedTarball(path.join(packDir, tarball)); + // Preflight the exact packed version so reruns skip deterministically; + // the conflict handling below remains a race fallback. + const preflight = await $`npm view ${`${packedTarball.name}@${packedTarball.version}`} version`.quiet().nothrow(); + if (preflight.exitCode === 0 && preflight.stdout.toString().trim()) { + console.log(`Skipping ${packedTarball.name} (version already published)`); + return; + } + const result = await $`npm publish ${packedTarball.path} --access public`.quiet().nothrow(); const output = `${result.stdout.toString()}${result.stderr.toString()}`.trim(); if (output) console.log(output); if (result.exitCode !== 0) { - // Idempotent re-runs: tolerate this exact version already being on the - // registry (the `bun publish --tolerate-republish` equivalent), but - // surface every other failure. + // A concurrent publisher may win after the preflight. if (isVersionAlreadyPublished(output)) { - console.log(`Skipping ${name} (version already published)`); + console.log(`Skipping ${packedTarball.name} (version already published)`); return; } process.exit(result.exitCode ?? 1); @@ -261,9 +286,12 @@ async function packAndPublish(dir: string, name: string): Promise { } } -/** Match npm's rejection when this exact version already exists on the registry. */ -function isVersionAlreadyPublished(output: string): boolean { - return /cannot publish over the previously published version|EPUBLISHCONFLICT/i.test(output); +/** + * npm's stable machine codes for an existing exact version: + * `E409` from a registry conflict and `EPUBLISHCONFLICT` from npm. + */ +export function isVersionAlreadyPublished(output: string): boolean { + return /npm error code (E409|EPUBLISHCONFLICT)\b/i.test(output); } async function publishGeneratedLeafPackage(leaf: GeneratedLeafPackage): Promise {