publish.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  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 { parseArgs } from 'node:util'
  18. import { releaseFamily } from './families.ts'
  19. import { attempt, isEntry, run } from './process.ts'
  20. import { packedIdentity, readPublishOrder } from './tarball.ts'
  21. /** npm access level for every package this repository publishes. */
  22. const ACCESS = 'restricted'
  23. /** What the registry knows about one version. */
  24. type RegistryState =
  25. | { readonly kind: 'absent' }
  26. | { readonly kind: 'present'; readonly integrity: string }
  27. /**
  28. * The subresource integrity string npm records for a tarball.
  29. * @param tarball - absolute tarball path.
  30. * @returns A `sha512-<base64>` string.
  31. */
  32. function integrityOf(tarball: string): string {
  33. return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`
  34. }
  35. /**
  36. * Ask the registry whether a version exists, and with what integrity.
  37. * @param name - package name.
  38. * @param version - package version.
  39. * @returns The registry state for that version.
  40. */
  41. function registryState(name: string, version: string): RegistryState {
  42. const result = attempt('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'])
  43. if (result.status !== 0) {
  44. const output = `${result.stdout}${result.stderr}`
  45. if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' }
  46. throw new Error(`npm view ${name}@${version} failed:\n${output}`)
  47. }
  48. const parsed: unknown = JSON.parse(result.stdout)
  49. if (typeof parsed !== 'string' || parsed === '') {
  50. throw new Error(`registry reported no dist.integrity for ${name}@${version}`)
  51. }
  52. return { kind: 'present', integrity: parsed }
  53. }
  54. /** Publish the family named by `--family` from the directory named by `--from`. */
  55. function main(): void {
  56. const { values } = parseArgs({
  57. options: { family: { type: 'string' }, from: { type: 'string' } },
  58. allowPositionals: false,
  59. })
  60. if (values.family === undefined || values.from === undefined) {
  61. throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory>')
  62. }
  63. const family = releaseFamily(values.family)
  64. const directory = resolve(process.cwd(), values.from)
  65. let published = 0
  66. let skipped = 0
  67. for (const filename of readPublishOrder(directory)) {
  68. const tarball = join(directory, filename)
  69. const { name, version } = packedIdentity(tarball)
  70. const state = registryState(name, version)
  71. if (state.kind === 'present') {
  72. const local = integrityOf(tarball)
  73. if (state.integrity !== local) {
  74. throw new Error(
  75. `${name}@${version} is already published with different content`
  76. + `\n registry: ${state.integrity}\n packed: ${local}`
  77. + '\nBump the version, or investigate why the build is not reproducible.',
  78. )
  79. }
  80. console.log(`release publish: ${name}@${version} already published, skipping`)
  81. skipped += 1
  82. continue
  83. }
  84. // A prerelease version never takes the latest dist-tag.
  85. const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
  86. run('npm', ['publish', tarball, '--access', ACCESS, ...tagArgs])
  87. published += 1
  88. }
  89. console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
  90. }
  91. if (isEntry(import.meta.url)) main()