publish-npm.mjs 7.3 KB

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