From b7700a82ef45b2519c8a45575d76f180b44c402d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 9 Jul 2026 15:32:20 +0200 Subject: [PATCH] fix(cli): retry transient npm publish failures --- .../opencode/script/kilocode/npm-publish.ts | 32 +++++ packages/opencode/script/publish.ts | 10 +- .../test/kilocode/npm-publish.test.ts | 133 ++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/script/kilocode/npm-publish.ts create mode 100644 packages/opencode/test/kilocode/npm-publish.test.ts diff --git a/packages/opencode/script/kilocode/npm-publish.ts b/packages/opencode/script/kilocode/npm-publish.ts new file mode 100644 index 00000000000..2744ff675e4 --- /dev/null +++ b/packages/opencode/script/kilocode/npm-publish.ts @@ -0,0 +1,32 @@ +export namespace NpmPublish { + const attempts = 3 + const base = 10_000 + const jitter = 5_000 + + export async function retry(input: { + name: string + version: string + run: () => Promise + exists: () => Promise + sleep?: (ms: number) => Promise + }) { + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + await input.run() + return + } catch (err) { + if (await input.exists()) { + console.log(`published ${input.name}@${input.version} despite a failed npm publish command`) + return + } + if (attempt === attempts) throw err + + const delay = attempt * base + Math.floor(Math.random() * jitter) + console.warn( + `npm publish ${input.name}@${input.version} failed (attempt ${attempt}/${attempts}), retrying in ${delay / 1000}s`, + ) + await (input.sleep ?? Bun.sleep)(delay) + } + } + } +} diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 67cfc3ee427..bd94c7c0e3e 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -3,6 +3,7 @@ import { $ } from "bun" import pkg from "../package.json" import { Script } from "@opencode-ai/script" import { fileURLToPath } from "url" +import { NpmPublish } from "./kilocode/npm-publish" // kilocode_change const dir = fileURLToPath(new URL("..", import.meta.url)) process.chdir(dir) @@ -20,7 +21,14 @@ async function publish(dir: string, name: string, version: string) { return } await $`bun pm pack`.cwd(dir) - await $`npm publish *.tgz --access public --tag ${Script.channel} --provenance`.cwd(dir) // kilocode_change + // kilocode_change start + await NpmPublish.retry({ + name, + version, + run: () => $`npm publish *.tgz --access public --tag ${Script.channel} --provenance`.cwd(dir), + exists: () => published(name, version), + }) + // kilocode_change end } const binaries: Record = {} diff --git a/packages/opencode/test/kilocode/npm-publish.test.ts b/packages/opencode/test/kilocode/npm-publish.test.ts new file mode 100644 index 00000000000..62e94349865 --- /dev/null +++ b/packages/opencode/test/kilocode/npm-publish.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test" +import { NpmPublish } from "../../script/kilocode/npm-publish" + +describe("npm publish retry", () => { + test("returns after the first successful attempt", async () => { + const calls = { run: 0, exists: 0, sleep: 0 } + + await NpmPublish.retry({ + name: "@kilocode/test", + version: "1.0.0", + run: async () => { + calls.run++ + }, + exists: async () => { + calls.exists++ + return false + }, + sleep: async () => { + calls.sleep++ + }, + }) + + expect(calls).toEqual({ run: 1, exists: 0, sleep: 0 }) + }) + + test("accepts a version that landed after a failed command", async () => { + const calls = { run: 0, exists: 0, sleep: 0 } + const err = new Error("connection closed") + + await NpmPublish.retry({ + name: "@kilocode/test", + version: "1.0.0", + run: async () => { + calls.run++ + throw err + }, + exists: async () => { + calls.exists++ + return true + }, + sleep: async () => { + calls.sleep++ + }, + }) + + expect(calls).toEqual({ run: 1, exists: 1, sleep: 0 }) + }) + + test("retries an unpublished version after a delay", async () => { + const calls = { run: 0, exists: 0 } + const delays: number[] = [] + const err = new Error("registry unavailable") + + await NpmPublish.retry({ + name: "@kilocode/test", + version: "1.0.0", + run: async () => { + calls.run++ + if (calls.run === 1) throw err + }, + exists: async () => { + calls.exists++ + return false + }, + sleep: async (ms) => { + delays.push(ms) + }, + }) + + expect(calls).toEqual({ run: 2, exists: 1 }) + expect(delays).toHaveLength(1) + expect(delays[0]).toBeGreaterThanOrEqual(10_000) + expect(delays[0]).toBeLessThan(15_000) + }) + + test("accepts a version that becomes visible after a retry", async () => { + const calls = { run: 0, exists: 0 } + const delays: number[] = [] + const err = new Error("registry response lost") + + await NpmPublish.retry({ + name: "@kilocode/test", + version: "1.0.0", + run: async () => { + calls.run++ + throw err + }, + exists: async () => { + calls.exists++ + return calls.exists === 2 + }, + sleep: async (ms) => { + delays.push(ms) + }, + }) + + expect(calls).toEqual({ run: 2, exists: 2 }) + expect(delays).toHaveLength(1) + }) + + test("preserves the error after all attempts fail", async () => { + const calls = { run: 0, exists: 0 } + const delays: number[] = [] + const err = new Error("permission denied") + + const failure = await NpmPublish.retry({ + name: "@kilocode/test", + version: "1.0.0", + run: async () => { + calls.run++ + throw err + }, + exists: async () => { + calls.exists++ + return false + }, + sleep: async (ms) => { + delays.push(ms) + }, + }).then( + () => undefined, + (error) => error, + ) + + expect(failure).toBe(err) + expect(calls).toEqual({ run: 3, exists: 3 }) + expect(delays).toHaveLength(2) + expect(delays[0]).toBeGreaterThanOrEqual(10_000) + expect(delays[0]).toBeLessThan(15_000) + expect(delays[1]).toBeGreaterThanOrEqual(20_000) + expect(delays[1]).toBeLessThan(25_000) + }) +})