Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 93 additions & 27 deletions packages/opencode/script/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> => {
if (await alreadyPublished(name)) {
console.log(`skipping ${name}@${version} (already published)`)
return
return true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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<string, string> = {}
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<string, string> = 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 <pkg> 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"))
Expand Down Expand Up @@ -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)`)
Loading