From 1ec1d0a484c25ca050a0c500bfb9702e0a8df81b Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Thu, 16 Jul 2026 00:44:31 +0530 Subject: [PATCH 1/2] fix(release): unblock dcode-ai publish when a platform is rate limited --- packages/opencode/script/publish.ts | 64 ++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 80a9c72ec997..060d87fddcfe 100644 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -53,26 +53,50 @@ const alreadyPublished = async (name: string) => { const res = await fetch(`https://registry.npmjs.org/${name}/${version}`) return res.ok } -const publish = async (pkgDir: string, name: string) => { +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 +} + +// 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`) + if (!output.includes("E429")) { + 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 + } + const delay = parseRetryAfter(output) ?? Math.min(attempt * 90_000, 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 failures: string[] = [] const optionalDependencies: Record = {} for (const built of names) { const npmName = npmNameFor(built) @@ -84,8 +108,12 @@ for (const built of names) { 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) + // Always declare the platform dep at this version: npm only resolves the + // current platform's optional dep at install time, so listing one that is still + // rate limited is harmless, and a later re-run that publishes it makes it + // available without needing to touch the already-published wrapper. optionalDependencies[npmName] = version + if (!(await publish(pkgDir, npmName))) failures.push(npmName) } // Assemble the wrapper package from a clean generated manifest (the source @@ -119,6 +147,20 @@ fs.writeFileSync( ), ) -await publish(wrapperDir, NPM_NAME) +// Always publish the wrapper, even if some platform binaries were rate limited. +// The wrapper is a few hundred KB and never hits E429; gating it on every platform +// (one 184MB Windows binary that npm throttles) is exactly what left `dcode-ai` +// unpublished and un-installable before. +if (!(await publish(wrapperDir, NPM_NAME))) failures.push(NPM_NAME) -console.log(dryRun ? "dry run complete" : `published ${NPM_NAME}@${version} (${names.length} platform packages)`) +if (dryRun) { + console.log("dry run complete") +} else if (failures.length > 0) { + console.error( + `published what it could, but these packages were left for a re-run: ${failures.join(", ")}. ` + + `Re-running this workflow with the same version publishes only the missing ones.`, + ) + process.exit(1) +} else { + console.log(`published ${NPM_NAME}@${version} (${names.length} platform packages)`) +} From 06145e7dc1f21ccfa2f5cd9738396ed2446a5e3d Mon Sep 17 00:00:00 2001 From: CreatorGhost Date: Thu, 16 Jul 2026 00:59:22 +0530 Subject: [PATCH 2/2] fix(release): publish wrapper first and harden publish retries Address review: publish dcode-ai wrapper before per-platform binaries so a rate limit/timeout never leaves it unpublished; stop alreadyPublished from throwing; treat 'cannot publish over' as success; capped exponential backoff with a Retry-After:0 guard; broaden 429 detection; fail dry-run on any failure. --- packages/opencode/script/publish.ts | 92 ++++++++++++++++++----------- 1 file changed, 58 insertions(+), 34 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 060d87fddcfe..d1eb528e701c 100644 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -50,8 +50,16 @@ 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 @@ -78,7 +86,15 @@ const publish = async (pkgDir: string, name: string): Promise => { const result = await $`npm publish ${flags}`.cwd(pkgDir).quiet().nothrow() if (result.exitCode === 0) return true const output = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}` - if (!output.includes("E429")) { + // 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 } @@ -86,7 +102,11 @@ const publish = async (pkgDir: string, name: string): Promise => { console.error(`npm rate limited ${name} after ${attempt} attempts; leaving it for a re-run`) return false } - const delay = parseRetryAfter(output) ?? Math.min(attempt * 90_000, 300_000) + // 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) } @@ -97,27 +117,20 @@ const publish = async (pkgDir: string, name: string): Promise => { const npmNameFor = (built: string) => built.replace(/^dcode-/, `${NPM_NAME}-`) const failures: string[] = [] -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)) - // Always declare the platform dep at this version: npm only resolves the - // current platform's optional dep at install time, so listing one that is still - // rate limited is harmless, and a later re-run that publishes it makes it - // available without needing to touch the already-published wrapper. - optionalDependencies[npmName] = version - if (!(await publish(pkgDir, npmName))) failures.push(npmName) -} -// 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")) @@ -147,20 +160,31 @@ fs.writeFileSync( ), ) -// Always publish the wrapper, even if some platform binaries were rate limited. -// The wrapper is a few hundred KB and never hits E429; gating it on every platform -// (one 184MB Windows binary that npm throttles) is exactly what left `dcode-ai` -// unpublished and un-installable before. if (!(await publish(wrapperDir, NPM_NAME))) failures.push(NPM_NAME) -if (dryRun) { - console.log("dry run complete") -} else if (failures.length > 0) { +// 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( - `published what it could, but these packages were left for a re-run: ${failures.join(", ")}. ` + + `${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) -} else { - console.log(`published ${NPM_NAME}@${version} (${names.length} platform packages)`) } +console.log(dryRun ? "dry run complete" : `published ${NPM_NAME}@${version} (${names.length} platform packages)`)