mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(release): make publication retry, space out, and skip what landed
A landlock publication failed with `E409 Failed to save packument` on the second of three packages. The registry answers a write it could not commit that way, and publishing several packages back to back is what provokes it. Neither publish path could recover. The native sequence published from a shell loop of bare `npm publish` calls: no retry, and no way to resume, because the registry rejects a repeat of an existing version permanently — so a failure partway through left the release stuck. publish.ts skipped versions already present, which made a re-run safe, but had no retry either. Both paths now attempt a tarball up to four times, space writes at least two seconds apart, and back off 2s/4s/8s between attempts. Every retry re-reads the registry first, because a reported failure can answer a write that landed anyway: a version that now exists with this tarball's integrity counts as published rather than as one to place again. That same re-read is what turns a mid-run `E403 cannot publish over the previously published versions` into a skip when the bytes match, and leaves it a hard failure when they do not. The native sequence gets the registry comparison publish.ts already had, through its own script rather than shared code — the two sequences keep separate publication paths. Its publish job now checks out the repository, which the shell loop did not need. Verified against a scripted registry: a clean publish, one E409 then success, an E409 whose write landed anyway, E409 on every attempt (fails after four), and a version already present with matching integrity (publishes nothing).
This commit is contained in:
@@ -15,16 +15,46 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { attempt, isEntry, run } from './process.ts'
|
||||
import { attempt, isEntry } from './process.ts'
|
||||
import { packedIdentity, readPublishOrder } from './tarball.ts'
|
||||
|
||||
/**
|
||||
* Registry codes that answer a write which did not settle, rather than a
|
||||
* rejection of what was sent. `E409 Failed to save packument` is the one this
|
||||
* sequence actually hits: publishing several packages in a row can outrun the
|
||||
* registry's own processing. A rejected payload (`E403` over an existing
|
||||
* version, a malformed manifest) never clears on a retry and must surface.
|
||||
*/
|
||||
const TRANSIENT_PUBLISH_CODES = ['E409', 'E429', 'E500', 'E502', 'E503', 'E504', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'] as const
|
||||
|
||||
/** How many times one tarball's publish is attempted before the run fails. */
|
||||
const PUBLISH_ATTEMPTS = 4
|
||||
|
||||
/**
|
||||
* Shortest gap between two publishes, and the first retry backoff.
|
||||
*
|
||||
* The registry needs a moment to commit a packument before the next write; back
|
||||
* to back publishes are what produce `E409`.
|
||||
*/
|
||||
const PUBLISH_SPACING_MS = 2_000
|
||||
|
||||
/** What the registry knows about one version. */
|
||||
type RegistryState =
|
||||
| { readonly kind: 'absent' }
|
||||
| { readonly kind: 'present'; readonly integrity: string }
|
||||
|
||||
/**
|
||||
* Whether a failed publish is worth another attempt.
|
||||
* @param output - combined npm output.
|
||||
* @returns True when the registry reported a write it did not commit.
|
||||
*/
|
||||
function isTransientFailure(output: string): boolean {
|
||||
return TRANSIENT_PUBLISH_CODES.some(code => output.includes(`code ${code}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* The subresource integrity string npm records for a tarball.
|
||||
* @param tarball - absolute tarball path.
|
||||
@@ -54,8 +84,47 @@ function registryState(name: string, version: string): RegistryState {
|
||||
return { kind: 'present', integrity: parsed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish one tarball, retrying a registry write that did not settle.
|
||||
*
|
||||
* Every retry re-reads the registry first, because `E409` can answer a write
|
||||
* that landed anyway: republishing a version that now exists fails permanently,
|
||||
* so the same integrity appearing under the failed attempt counts as success.
|
||||
* @param tarball - absolute tarball path.
|
||||
* @param name - package name the tarball declares.
|
||||
* @param version - package version the tarball declares.
|
||||
*/
|
||||
async function publishTarball(tarball: string, name: string, version: string): Promise<void> {
|
||||
// A prerelease version never takes the latest dist-tag.
|
||||
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
||||
for (let tries = 1; tries <= PUBLISH_ATTEMPTS; tries += 1) {
|
||||
// No --access: the sequences do not share one access level, so a
|
||||
// command-line flag could not serve both and would override the manifest
|
||||
// that does. Each packed manifest decides, and
|
||||
// check-workspace-constraints holds every manifest to its sequence's level.
|
||||
const result = attempt('npm', ['publish', tarball, ...tagArgs])
|
||||
const output = `${result.stdout}${result.stderr}`
|
||||
if (result.status === 0) return
|
||||
|
||||
const settled = registryState(name, version)
|
||||
if (settled.kind === 'present' && settled.integrity === integrityOf(tarball)) {
|
||||
console.log(`release publish: ${name}@${version} landed despite a reported failure, continuing`)
|
||||
return
|
||||
}
|
||||
if (tries === PUBLISH_ATTEMPTS || !isTransientFailure(output)) {
|
||||
throw new Error(`npm publish ${name}@${version} failed:\n${output}`)
|
||||
}
|
||||
const backoff = PUBLISH_SPACING_MS * 2 ** (tries - 1)
|
||||
console.log(
|
||||
`release publish: ${name}@${version} hit a transient registry failure`
|
||||
+ ` (attempt ${String(tries)} of ${String(PUBLISH_ATTEMPTS)}), retrying in ${String(backoff)}ms`,
|
||||
)
|
||||
await sleep(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish the family named by `--family` from the directory named by `--from`. */
|
||||
function main(): void {
|
||||
async function main(): Promise<void> {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, from: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
@@ -86,17 +155,15 @@ function main(): void {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
// A prerelease version never takes the latest dist-tag.
|
||||
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
||||
// No --access: the sequences do not share one access level, so a
|
||||
// command-line flag could not serve both and would override the manifest
|
||||
// that does. Each packed manifest decides, and
|
||||
// check-workspace-constraints holds every manifest to its sequence's level.
|
||||
run('npm', ['publish', tarball, ...tagArgs])
|
||||
// Space out the writes: the gap belongs between publishes, so a run that
|
||||
// only skips does not wait at all.
|
||||
if (published > 0) await sleep(PUBLISH_SPACING_MS)
|
||||
await publishTarball(tarball, name, version)
|
||||
console.log(`release publish: ${name}@${version} published`)
|
||||
published += 1
|
||||
}
|
||||
|
||||
console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
if (isEntry(import.meta.url)) await main()
|
||||
|
||||
Reference in New Issue
Block a user