publish-npm.mjs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import assert from 'node:assert/strict'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import { createHash } from 'node:crypto'
  5. import { execFileSync } from 'node:child_process'
  6. import { pathToFileURL } from 'node:url'
  7. import { packageFiles, checkPublishableManifest, checkEmbeddingPackage, checkMetaPackage } from './tar.mjs'
  8. // The official registry is the only release target. The environment override exists so
  9. // the publish path itself can be exercised against a first-publication fixture.
  10. const registry = process.env.SCRIPTOR_NPM_REGISTRY ?? 'https://registry.npmjs.org/'
  11. const mainName = '@linfengqaqtat/dsh-scriptor'
  12. const embeddingName = 'webnovel-embedding-provider'
  13. const metaName = '@linfengqaqtat/dsh-scriptor-full'
  14. const digest = (bytes, algorithm = 'sha256', encoding = 'hex') => createHash(algorithm).update(bytes).digest(encoding)
  15. export function validateReleaseAssets(directory, expectedTag, expectedCommit) {
  16. assert.match(expectedTag ?? '', /^scriptor-v\d+\.\d+\.\d+-preview\.[1-9]\d*$/)
  17. assert.match(expectedCommit ?? '', /^[a-f0-9]{40}$/)
  18. const read = filename => {
  19. assert.match(filename, /^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Release assets must be flat filenames')
  20. const file = path.join(directory, filename)
  21. assert.ok(fs.lstatSync(file).isFile(), 'Release assets must be regular files')
  22. return fs.readFileSync(file)
  23. }
  24. const checksums = new Map()
  25. for (const line of read('SHA256SUMS').toString('utf8').trim().split(/\r?\n/)) {
  26. const match = /^([a-f0-9]{64}) ([A-Za-z0-9][A-Za-z0-9._-]*)$/.exec(line)
  27. assert.ok(match, 'Malformed release checksum')
  28. assert.ok(!checksums.has(match[2]), 'Duplicate release checksum')
  29. checksums.set(match[2], match[1])
  30. }
  31. const checkedRead = filename => {
  32. const bytes = read(filename)
  33. assert.equal(digest(bytes), checksums.get(filename), `Release checksum mismatch: ${filename}`)
  34. return bytes
  35. }
  36. const manifest = JSON.parse(checkedRead('release-manifest.json'))
  37. assert.equal(manifest.schemaVersion, 1)
  38. assert.equal(manifest.tag, expectedTag)
  39. assert.equal(`scriptor-v${manifest.version}`, expectedTag)
  40. assert.equal(manifest.publicCommit, expectedCommit)
  41. assert.equal(manifest.prerelease, true)
  42. assert.equal(manifest.packageName, mainName)
  43. assert.equal(manifest.filename, `linfengqaqtat-dsh-scriptor-${manifest.version}.tgz`)
  44. assert.equal(manifest.optionalPackages?.length, 2)
  45. const optional = new Map(manifest.optionalPackages.map(item => [item.name, item]))
  46. assert.deepEqual([...optional.keys()].sort(), [embeddingName, metaName].sort())
  47. const embedding = optional.get(embeddingName)
  48. const meta = optional.get(metaName)
  49. assert.match(embedding.version, /^\d+\.\d+\.\d+$/)
  50. assert.equal(embedding.tarball, `webnovel-embedding-provider-${embedding.version}.tgz`)
  51. assert.equal(meta.version, manifest.version)
  52. assert.equal(meta.tarball, `linfengqaqtat-dsh-scriptor-full-${meta.version}.tgz`)
  53. const assets = new Map()
  54. for (const item of manifest.assets) {
  55. assert.ok(!assets.has(item.file), 'Duplicate manifest asset')
  56. assert.equal(item.sha256, checksums.get(item.file), 'Manifest/checksum disagreement')
  57. assets.set(item.file, item.sha256)
  58. }
  59. const packages = [
  60. { name: mainName, version: manifest.version, tarball: manifest.filename },
  61. embedding, meta,
  62. ].map(item => {
  63. const bytes = checkedRead(item.tarball)
  64. assert.equal(digest(bytes), assets.get(item.tarball), 'Package absent from release manifest')
  65. const filename = path.join(directory, item.tarball)
  66. const files = packageFiles(filename)
  67. const pkg = JSON.parse(files.get('package.json'))
  68. assert.equal(pkg.name, item.name)
  69. assert.equal(pkg.version, item.version)
  70. assert.equal(pkg.repository?.url, 'https://github.com/lingfengQAQ/webnovel-writer.git')
  71. checkPublishableManifest(pkg)
  72. return { ...item, filename, integrity: `sha512-${digest(bytes, 'sha512', 'base64')}` }
  73. })
  74. checkEmbeddingPackage(packages[1].filename, embedding.version)
  75. checkMetaPackage(packages[2].filename, manifest.version, embedding.version)
  76. return packages
  77. }
  78. // A retry must never overwrite an existing name/version or move latest.
  79. export function registryStatus(pkg, metadata) {
  80. assert.notEqual(metadata?.['dist-tags']?.latest, pkg.version, 'Preview version must not occupy latest')
  81. const existing = metadata?.versions?.[pkg.version]
  82. if (!existing) return 'missing'
  83. assert.equal(existing.dist?.integrity, pkg.integrity, `Registry version has different bytes: ${pkg.name}@${pkg.version}`)
  84. return 'identical'
  85. }
  86. async function metadataFor(name) {
  87. const response = await fetch(`${registry}${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(30000), headers: { accept: 'application/json' } })
  88. if (response.status === 404) return undefined
  89. assert.ok(response.ok, `Registry lookup failed (${response.status}): ${name}`)
  90. return response.json()
  91. }
  92. async function verifyRegistry(packages) {
  93. for (const pkg of packages) {
  94. let verified = false
  95. for (let attempt = 0; attempt < 8; attempt++) {
  96. const metadata = await metadataFor(pkg.name)
  97. if (registryStatus(pkg, metadata) === 'identical' && metadata['dist-tags']?.preview === pkg.version) {
  98. verified = true
  99. break
  100. }
  101. await new Promise(resolve => setTimeout(resolve, 3000))
  102. }
  103. assert.ok(verified, `Registry version/preview not visible: ${pkg.name}@${pkg.version}`)
  104. console.log(`[npm] verified ${pkg.name}@${pkg.version}`)
  105. }
  106. }
  107. async function main() {
  108. const args = process.argv.slice(2)
  109. const directoryIndex = args.indexOf('--assets')
  110. assert.ok(directoryIndex >= 0 && args[directoryIndex + 1], 'Usage: node scripts/release/publish-npm.mjs --assets <directory> [--publish|--verify-only]')
  111. assert.ok(!(args.includes('--publish') && args.includes('--verify-only')), 'Choose one mode')
  112. const directory = path.resolve(args[directoryIndex + 1])
  113. const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', windowsHide: true }).trim()
  114. const packages = validateReleaseAssets(directory, process.env.RELEASE_TAG, commit)
  115. if (args.includes('--verify-only')) return verifyRegistry(packages)
  116. const npm = [process.env.NPM_CLI_ENTRY, path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), path.resolve(path.dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')].filter(Boolean).find(file => fs.existsSync(file))
  117. assert.ok(npm, 'npm-cli.js was not found; set NPM_CLI_ENTRY')
  118. const runNpm = rest => execFileSync(process.execPath, [npm, ...rest], { stdio: 'inherit', windowsHide: true })
  119. const plan = []
  120. for (const pkg of packages) {
  121. plan.push({ pkg, state: registryStatus(pkg, await metadataFor(pkg.name)) })
  122. }
  123. for (const { pkg } of plan) {
  124. runNpm(['publish', pkg.filename, '--dry-run', '--ignore-scripts', '--access', 'public', '--tag', 'preview', `--registry=${registry}`])
  125. }
  126. if (!args.includes('--publish')) return console.log('[npm] preflight passed; no packages published')
  127. assert.equal(process.env.GITHUB_ACTIONS, 'true', 'Publish with provenance from GitHub Actions')
  128. assert.ok(process.env.NODE_AUTH_TOKEN, 'Configure repository secret NPM_TOKEN before publishing')
  129. for (const { pkg, state } of plan) {
  130. if (state === 'identical') console.log(`[npm] already published with identical bytes: ${pkg.name}@${pkg.version}`)
  131. else runNpm(['publish', pkg.filename, '--ignore-scripts', '--access', 'public', '--tag', 'preview', '--provenance', `--registry=${registry}`])
  132. }
  133. await verifyRegistry(packages)
  134. }
  135. if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) await main()