fix(cli): retry transient npm publish failures

This commit is contained in:
marius-kilocode
2026-07-09 15:32:20 +02:00
parent 2c175fbd25
commit b7700a82ef
3 changed files with 174 additions and 1 deletions
@@ -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<unknown>
exists: () => Promise<boolean>
sleep?: (ms: number) => Promise<void>
}) {
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)
}
}
}
}
+9 -1
View File
@@ -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<string, string> = {}
@@ -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)
})
})