publish.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. /**
  2. * Publish one packed release family from the tarballs the pack step produced.
  3. *
  4. * Publication is decided per package against the registry, never from a list of
  5. * "what this release includes": a version the registry lacks is published, a
  6. * version whose published tarball has the same integrity is skipped, and a
  7. * version whose published tarball differs fails the run — that last case means
  8. * the content changed without a version bump
  9. * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
  10. *
  11. * Skipping on identical integrity is what makes re-running the publish step over
  12. * the same artifact safe.
  13. */
  14. import { createHash } from 'node:crypto'
  15. import { readFileSync } from 'node:fs'
  16. import { join, resolve } from 'node:path'
  17. import { setTimeout as sleep } from 'node:timers/promises'
  18. import { parseArgs } from 'node:util'
  19. import { releaseFamily } from './families.ts'
  20. import { attempt, attemptEchoed, isEntry } from './process.ts'
  21. import { packedIdentity, readPublishOrder } from './tarball.ts'
  22. /**
  23. * Registry codes that answer a write which did not settle, rather than a
  24. * rejection of what was sent. `E409 Failed to save packument` is the one this
  25. * sequence actually hits: publishing several packages in a row can outrun the
  26. * registry's own processing. A rejected payload (`E403` over an existing
  27. * version, a malformed manifest) never clears on a retry and must surface.
  28. */
  29. const TRANSIENT_PUBLISH_CODES = ['E409', 'E429', 'E500', 'E502', 'E503', 'E504', 'ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN'] as const
  30. /** How many times one tarball's publish is attempted before the run fails. */
  31. const PUBLISH_ATTEMPTS = 4
  32. /**
  33. * Shortest gap between two publishes, and the first retry backoff.
  34. *
  35. * The registry needs a moment to commit a packument before the next write; back
  36. * to back publishes are what produce `E409`.
  37. */
  38. const PUBLISH_SPACING_MS = 2_000
  39. /** What the registry knows about one version. */
  40. type RegistryState =
  41. | { readonly kind: 'absent' }
  42. | { readonly kind: 'present'; readonly integrity: string }
  43. /**
  44. * Whether a failed publish is worth another attempt.
  45. * @param output - combined npm output.
  46. * @returns True when the registry reported a write it did not commit.
  47. */
  48. function isTransientFailure(output: string): boolean {
  49. return TRANSIENT_PUBLISH_CODES.some(code => output.includes(`code ${code}`))
  50. }
  51. /**
  52. * The subresource integrity string npm records for a tarball.
  53. * @param tarball - absolute tarball path.
  54. * @returns A `sha512-<base64>` string.
  55. */
  56. function integrityOf(tarball: string): string {
  57. return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`
  58. }
  59. /**
  60. * Ask the registry whether a version exists, and with what integrity.
  61. * @param name - package name.
  62. * @param version - package version.
  63. * @returns The registry state for that version.
  64. */
  65. function registryState(name: string, version: string): RegistryState {
  66. const result = attempt('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'])
  67. if (result.status !== 0) {
  68. const output = `${result.stdout}${result.stderr}`
  69. if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' }
  70. throw new Error(`npm view ${name}@${version} failed:\n${output}`)
  71. }
  72. const parsed: unknown = JSON.parse(result.stdout)
  73. if (typeof parsed !== 'string' || parsed === '') {
  74. throw new Error(`registry reported no dist.integrity for ${name}@${version}`)
  75. }
  76. return { kind: 'present', integrity: parsed }
  77. }
  78. /**
  79. * Publish one tarball, retrying a registry write that did not settle.
  80. *
  81. * Every retry re-reads the registry first, because `E409` can answer a write
  82. * that landed anyway: republishing a version that now exists fails permanently,
  83. * so the same integrity appearing under the failed attempt counts as success.
  84. * @param tarball - absolute tarball path.
  85. * @param name - package name the tarball declares.
  86. * @param version - package version the tarball declares.
  87. * @param distTag - explicit npm dist-tag, or undefined for npm's `latest` default.
  88. */
  89. async function publishTarball(
  90. tarball: string,
  91. name: string,
  92. version: string,
  93. distTag: string | undefined,
  94. ): Promise<void> {
  95. const tagArgs = distTag === undefined ? [] : ['--tag', distTag]
  96. for (let tries = 1; tries <= PUBLISH_ATTEMPTS; tries += 1) {
  97. // No --access: every release member declares its own publishConfig, and
  98. // a command-line flag would override it. check-workspace-constraints
  99. // requires a public access level on every release member.
  100. const result = attemptEchoed('npm', ['publish', tarball, ...tagArgs])
  101. const output = `${result.stdout}${result.stderr}`
  102. if (result.status === 0) return
  103. const settled = registryState(name, version)
  104. if (settled.kind === 'present' && settled.integrity === integrityOf(tarball)) {
  105. console.log(`release publish: ${name}@${version} landed despite a reported failure, continuing`)
  106. return
  107. }
  108. if (tries === PUBLISH_ATTEMPTS || !isTransientFailure(output)) {
  109. throw new Error(`npm publish ${name}@${version} failed:\n${output}`)
  110. }
  111. const backoff = PUBLISH_SPACING_MS * 2 ** (tries - 1)
  112. console.log(
  113. `release publish: ${name}@${version} hit a transient registry failure`
  114. + ` (attempt ${String(tries)} of ${String(PUBLISH_ATTEMPTS)}), retrying in ${String(backoff)}ms`,
  115. )
  116. await sleep(backoff)
  117. }
  118. }
  119. /** Publish the family named by `--family` from the directory named by `--from`. */
  120. async function main(): Promise<void> {
  121. const { values } = parseArgs({
  122. options: { family: { type: 'string' }, from: { type: 'string' } },
  123. allowPositionals: false,
  124. })
  125. if (values.family === undefined || values.from === undefined) {
  126. throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory>')
  127. }
  128. const family = releaseFamily(values.family)
  129. const directory = resolve(process.cwd(), values.from)
  130. // Every entry in the order settles as either published or already present, so
  131. // one counter answers "how far along is this run" for whoever is watching a
  132. // release that takes minutes per family.
  133. const order = readPublishOrder(directory)
  134. const total = String(order.length)
  135. let published = 0
  136. let skipped = 0
  137. for (const [index, filename] of order.entries()) {
  138. const progress = `[${String(index + 1)}/${total}]`
  139. const tarball = join(directory, filename)
  140. const { name, version } = packedIdentity(tarball)
  141. const state = registryState(name, version)
  142. if (state.kind === 'present') {
  143. const local = integrityOf(tarball)
  144. if (state.integrity !== local) {
  145. throw new Error(
  146. `${name}@${version} is already published with different content`
  147. + `\n registry: ${state.integrity}\n packed: ${local}`
  148. + '\nBump the version, or investigate why the build is not reproducible.',
  149. )
  150. }
  151. console.log(`release publish: ${progress} ${name}@${version} already published, skipping`)
  152. skipped += 1
  153. continue
  154. }
  155. // Space out the writes: the gap belongs between publishes, so a run that
  156. // only skips does not wait at all.
  157. if (published > 0) await sleep(PUBLISH_SPACING_MS)
  158. await publishTarball(tarball, name, version, family.distTagForVersion(version))
  159. console.log(`release publish: ${progress} ${name}@${version} published`)
  160. published += 1
  161. }
  162. console.log(
  163. `release publish: family ${family.id}, ${total} member(s),`
  164. + ` ${String(published)} published, ${String(skipped)} already present`,
  165. )
  166. }
  167. if (isEntry(import.meta.url)) await main()