diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 80a9c72ec997..d1eb528e701c 100644 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -50,46 +50,87 @@ if (names.length === 0) throw new Error("build produced no binaries") // a re-run after a partial failure is idempotent instead of 403-aborting. const alreadyPublished = async (name: string) => { if (dryRun) return false - const res = await fetch(`https://registry.npmjs.org/${name}/${version}`) - return res.ok + // A transient network/DNS failure here must not escape publish()'s "never + // throws" contract. Degrade to "not published" and let the publish attempt + // proceed; a genuine duplicate is caught below via npm's "cannot publish over". + try { + const res = await fetch(`https://registry.npmjs.org/${name}/${version}`) + return res.ok + } catch (error) { + console.error(`failed to check ${name}@${version}; will attempt publish:`, error) + return false + } +} +const MAX_ATTEMPTS = 8 + +// Honor an explicit Retry-After when npm surfaces one, else back off exponentially. +const parseRetryAfter = (output: string) => { + const match = output.match(/retry-after:\s*(\d+)/i) + if (!match) return undefined + const seconds = Number(match[1]) + return Number.isFinite(seconds) ? seconds * 1000 : undefined } -const publish = async (pkgDir: string, name: string) => { + +// Publishes one package. Returns true on success (or already-published), false on +// failure. It never throws: a single platform's npm publish rate limit (E429 on +// the large Windows binaries) must not abort the run before the tiny user-facing +// wrapper is published. Failed packages are collected and reported, and a re-run +// fills them idempotently via the already-published skip above. +const publish = async (pkgDir: string, name: string): Promise => { if (await alreadyPublished(name)) { console.log(`skipping ${name}@${version} (already published)`) - return + return true } - for (let attempt = 1; attempt <= 5; attempt++) { - console.log(`publishing ${name}@${version}${attempt > 1 ? ` (attempt ${attempt}/5)` : ""}`) + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + console.log(`publishing ${name}@${version}${attempt > 1 ? ` (attempt ${attempt}/${MAX_ATTEMPTS})` : ""}`) const result = await $`npm publish ${flags}`.cwd(pkgDir).quiet().nothrow() - if (result.exitCode === 0) return + if (result.exitCode === 0) return true const output = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}` - if (!output.includes("E429") || attempt === 5) throw new Error(output) - const delay = attempt * 60_000 - console.log(`npm rate limited ${name}; retrying in ${delay / 1000}s`) + // A concurrent/earlier run (or a preflight that failed to detect the existing + // version) already published this exact version: npm rejects the duplicate, + // which is success for our purposes, not a failure. + if (/cannot publish over/i.test(output)) { + console.log(`skipping ${name}@${version} (already published)`) + return true + } + const rateLimited = output.includes("E429") || /429 too many requests/i.test(output) + if (!rateLimited) { + console.error(`failed to publish ${name}:\n${output}`) + return false + } + if (attempt === MAX_ATTEMPTS) { + console.error(`npm rate limited ${name} after ${attempt} attempts; leaving it for a re-run`) + return false + } + // Prefer a valid Retry-After (capped), else capped exponential backoff. Guard + // against a Retry-After of 0, which would otherwise defeat the backoff. + const retryAfter = parseRetryAfter(output) + const delay = + retryAfter && retryAfter > 0 ? Math.min(retryAfter, 300_000) : Math.min(90_000 * 2 ** (attempt - 1), 300_000) + console.log(`npm rate limited ${name}; retrying in ${Math.round(delay / 1000)}s`) await Bun.sleep(delay) } + return false } // Map each built target (dcode-<...>) to its published npm name (dcode-ai-<...>). const npmNameFor = (built: string) => built.replace(/^dcode-/, `${NPM_NAME}-`) -const optionalDependencies: Record = {} -for (const built of names) { - const npmName = npmNameFor(built) - const pkgDir = path.join(dir, "dist", built) - const pkgJsonPath = path.join(pkgDir, "package.json") - const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) - pkgJson.name = npmName - pkgJson.description = `DCode CLI binary for ${pkgJson.os?.[0]} ${pkgJson.cpu?.[0]}` - pkgJson.license = "MIT" - pkgJson.repository = { type: "git", url: "git+https://github.com/CreatorGhost/TheCode.git" } - fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)) - await publish(pkgDir, npmName) - optionalDependencies[npmName] = version -} +const failures: string[] = [] -// Assemble the wrapper package from a clean generated manifest (the source -// package.json is private and carries workspace deps + raw TS exports). +// The wrapper's optional deps are fully determined by the build target list and +// do not depend on whether each platform publish succeeds, so compute them up +// front and publish the wrapper first. +const optionalDependencies: Record = Object.fromEntries( + names.map((built) => [npmNameFor(built), version]), +) + +// Publish the wrapper FIRST. It is the only package users install +// (`npm i -g dcode-ai`). Publishing it before the large per-platform binaries +// means a later rate limit or CI timeout can leave a platform for a re-run +// without ever leaving `dcode-ai` itself unpublished — the exact failure that +// made the CLI un-installable. A platform that is briefly missing degrades to the +// shim's "install manually" message and self-heals on the re-run. const wrapperDir = path.join(dir, "dist", NPM_NAME) fs.mkdirSync(path.join(wrapperDir, "bin"), { recursive: true }) fs.copyFileSync(path.join(dir, "bin", "dcode"), path.join(wrapperDir, "bin", "dcode")) @@ -119,6 +160,31 @@ fs.writeFileSync( ), ) -await publish(wrapperDir, NPM_NAME) +if (!(await publish(wrapperDir, NPM_NAME))) failures.push(NPM_NAME) +// Then publish each per-platform binary package. npm resolves only the current +// platform's optional dep at install time, so any left for a re-run do not break +// installs on other platforms. +for (const built of names) { + const npmName = npmNameFor(built) + const pkgDir = path.join(dir, "dist", built) + const pkgJsonPath = path.join(pkgDir, "package.json") + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) + pkgJson.name = npmName + pkgJson.description = `DCode CLI binary for ${pkgJson.os?.[0]} ${pkgJson.cpu?.[0]}` + pkgJson.license = "MIT" + pkgJson.repository = { type: "git", url: "git+https://github.com/CreatorGhost/TheCode.git" } + fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2)) + if (!(await publish(pkgDir, npmName))) failures.push(npmName) +} + +// Both real and dry-run modes must fail loudly if any package failed, so the +// workflow's validation-only mode can't green-light broken packages. +if (failures.length > 0) { + console.error( + `${dryRun ? "dry run" : "publish"} left these packages unpublished: ${failures.join(", ")}. ` + + `Re-running this workflow with the same version publishes only the missing ones.`, + ) + process.exit(1) +} console.log(dryRun ? "dry run complete" : `published ${NPM_NAME}@${version} (${names.length} platform packages)`)