deepseek-harness/scripts/release/publish.ts

179 lines
7.4 KiB
TypeScript
Raw Normal View History

/**
* Publish one packed release family from the tarballs the pack step produced.
*
* Publication is decided per package against the registry, never from a list of
* "what this release includes": a version the registry lacks is published, a
* version whose published tarball has the same integrity is skipped, and a
* version whose published tarball differs fails the run — that last case means
* the content changed without a version bump
fix(release): close the review findings on the release sequences The root manifest carries the dsh family version. bump writes it with the members, because the workspace constraint requires them to match, and that constraint now accepts a prerelease segment: without both, release:dsh 0.0.2 left the root behind and 0.0.1-rc.1 could satisfy neither check. The Landlock workflow no longer passes --access public, which overrode the restricted publishConfig this repository just adopted for those packages. Vendored change detection reads build inputs when a package publishes build output, and vendor/cordis publishes the src its export map already pointed at: its lib/ is untracked, so a real source edit read as 'nothing changed' and the next publish would fail on a version whose bytes moved. The next version also takes the last published version as its baseline, so a re-sync that restores a lower upstream version cannot recompute a version already on the registry, and bump confirms the registry carries what the newest tag names. Tag prefixes are constructed rather than recovered from a full tag, which a hyphenated version defeated. Pack runs group per ref so concurrent pull requests stop displacing each other, the publish job carries the global group, and the unused id-token permission is gone. Every release script sits behind an entry guard, which is what lets the pure judgements carry tests: tag naming, publish order and cycle reporting, version arithmetic, payload policy, and the change judgement. The Agent Note moves to implemented and states what shipped: one probe command, the registry confirmation that now exists, and byte reproducibility recorded as assumed rather than measured.
2026-08-11 01:26:36 +08:00
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
*
* Skipping on identical integrity is what makes re-running the publish step over
* the same artifact safe.
*/
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
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).
2026-08-13 15:18:03 +08:00
import { setTimeout as sleep } from 'node:timers/promises'
import { parseArgs } from 'node:util'
import { releaseFamily } from './families.ts'
import { attempt, attemptEchoed, isEntry } from './process.ts'
import { packedIdentity, readPublishOrder } from './tarball.ts'
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).
2026-08-13 15:18:03 +08:00
/**
* 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 }
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).
2026-08-13 15:18:03 +08:00
/**
* 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.
* @returns A `sha512-<base64>` string.
*/
function integrityOf(tarball: string): string {
return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`
}
/**
* Ask the registry whether a version exists, and with what integrity.
* @param name - package name.
* @param version - package version.
* @returns The registry state for that version.
*/
function registryState(name: string, version: string): RegistryState {
const result = attempt('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'])
if (result.status !== 0) {
const output = `${result.stdout}${result.stderr}`
if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' }
throw new Error(`npm view ${name}@${version} failed:\n${output}`)
}
const parsed: unknown = JSON.parse(result.stdout)
if (typeof parsed !== 'string' || parsed === '') {
throw new Error(`registry reported no dist.integrity for ${name}@${version}`)
}
return { kind: 'present', integrity: parsed }
}
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).
2026-08-13 15:18:03 +08:00
/**
* 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 = attemptEchoed('npm', ['publish', tarball, ...tagArgs])
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).
2026-08-13 15:18:03 +08:00
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`. */
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).
2026-08-13 15:18:03 +08:00
async function main(): Promise<void> {
const { values } = parseArgs({
options: { family: { type: 'string' }, from: { type: 'string' } },
allowPositionals: false,
})
if (values.family === undefined || values.from === undefined) {
throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory>')
}
const family = releaseFamily(values.family)
const directory = resolve(process.cwd(), values.from)
// Every entry in the order settles as either published or already present, so
// one counter answers "how far along is this run" for whoever is watching a
// release that takes minutes per family.
const order = readPublishOrder(directory)
const total = String(order.length)
let published = 0
let skipped = 0
for (const [index, filename] of order.entries()) {
const progress = `[${String(index + 1)}/${total}]`
const tarball = join(directory, filename)
const { name, version } = packedIdentity(tarball)
const state = registryState(name, version)
if (state.kind === 'present') {
const local = integrityOf(tarball)
if (state.integrity !== local) {
throw new Error(
`${name}@${version} is already published with different content`
+ `\n registry: ${state.integrity}\n packed: ${local}`
+ '\nBump the version, or investigate why the build is not reproducible.',
)
}
console.log(`release publish: ${progress} ${name}@${version} already published, skipping`)
skipped += 1
continue
}
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).
2026-08-13 15:18:03 +08:00
// 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: ${progress} ${name}@${version} published`)
published += 1
}
console.log(
`release publish: family ${family.id}, ${total} member(s),`
+ ` ${String(published)} published, ${String(skipped)} already present`,
)
}
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).
2026-08-13 15:18:03 +08:00
if (isEntry(import.meta.url)) await main()