publish-path.test.mjs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import fs from 'node:fs'
  4. import os from 'node:os'
  5. import path from 'node:path'
  6. import zlib from 'node:zlib'
  7. import { createHash } from 'node:crypto'
  8. import { execFileSync, spawnSync } from 'node:child_process'
  9. import { root } from '../version.mjs'
  10. const hash = bytes => createHash('sha256').update(bytes).digest('hex')
  11. const version = '0.1.0-preview.4'
  12. const publishConfig = { access: 'public', tag: 'preview', registry: 'https://registry.npmjs.org/' }
  13. function archive(files) {
  14. const rows = Object.entries(files).map(([name, text]) => {
  15. const body = Buffer.from(typeof text === 'object' ? JSON.stringify(text) : text)
  16. const header = Buffer.alloc(512)
  17. header.write(`package/${name}`)
  18. header.write(body.length.toString(8).padStart(11, '0'), 124)
  19. header.write('0', 156)
  20. return Buffer.concat([header, body, Buffer.alloc((512 - body.length % 512) % 512)])
  21. })
  22. return zlib.gzipSync(Buffer.concat([...rows, Buffer.alloc(1024)]))
  23. }
  24. const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', windowsHide: true }).trim()
  25. /** Build release assets whose bytes and manifest match what the publisher validates. */
  26. function writeAssets(directory) {
  27. const entries = [
  28. ['@linfengqaqtat/dsh-scriptor', version, `linfengqaqtat-dsh-scriptor-${version}.tgz`],
  29. ['webnovel-embedding-provider', '0.0.8', 'webnovel-embedding-provider-0.0.8.tgz'],
  30. ['@linfengqaqtat/dsh-scriptor-full', version, `linfengqaqtat-dsh-scriptor-full-${version}.tgz`],
  31. ]
  32. const packages = entries.map(([name, v, tarball]) => ({ name, version: v, tarball }))
  33. for (const [index, item] of packages.entries()) {
  34. const manifest = { name: item.name, version: item.version, license: 'GPL-3.0-only', publishConfig,
  35. repository: { url: 'https://github.com/lingfengQAQ/webnovel-writer.git' } }
  36. const files = { 'package.json': manifest, LICENSE: 'GNU GENERAL PUBLIC LICENSE', 'README.md': 'Example' }
  37. if (index === 1) Object.assign(files, { 'lib/index.js': '', 'lib/client.js': `window.__ModuleLoader__.load({ id: ${JSON.stringify(item.name)},`, 'cordis.patch.yml': '', 'MODEL_DIMENSIONS.md': '', 'THIRD_PARTY_NOTICES.md': 'Original notices' })
  38. if (index === 2) {
  39. manifest.dependencies = { '@linfengqaqtat/dsh-scriptor': version, 'webnovel-embedding-provider': '0.0.8' }
  40. manifest.dsh = { bundle: { patch: './cordis.patch.yml' } }
  41. files['cordis.patch.yml'] = fs.readFileSync(path.join(root, 'packages/meta/cordis.patch.yml'), 'utf8')
  42. }
  43. fs.writeFileSync(path.join(directory, item.tarball), archive(files))
  44. }
  45. const manifest = { schemaVersion: 1, packageName: packages[0].name, version, tag: `scriptor-v${version}`, publicCommit: commit, prerelease: true,
  46. filename: packages[0].tarball, optionalPackages: packages.slice(1),
  47. assets: packages.map(item => ({ file: item.tarball, sha256: hash(fs.readFileSync(path.join(directory, item.tarball))) })) }
  48. fs.writeFileSync(path.join(directory, 'release-manifest.json'), JSON.stringify(manifest))
  49. const files = [...manifest.assets, { file: 'release-manifest.json', sha256: hash(fs.readFileSync(path.join(directory, 'release-manifest.json'))) }]
  50. fs.writeFileSync(path.join(directory, 'SHA256SUMS'), files.map(item => `${item.sha256} ${item.file}`).join('\n') + '\n')
  51. return { manifest, packages }
  52. }
  53. const fixturePaths = {
  54. runner: path.join(root, 'scripts/release/tests/fixtures/publish-path-run.mjs'),
  55. publisher: path.join(root, 'scripts/release/publish-npm.mjs'),
  56. stub: path.join(root, 'scripts/release/tests/fixtures/stub-npm.mjs'),
  57. }
  58. /** Run the real publisher against the stub npm CLI and a first-publication registry. */
  59. function runPublisher({ assets, fixture, mode = '--publish', environment = {}, keepState = false }) {
  60. const result = spawnSync(process.execPath, [fixturePaths.runner, assets, fixture, ...mode.split(' ')], {
  61. cwd: root,
  62. encoding: 'utf8',
  63. windowsHide: true,
  64. timeout: 120000,
  65. env: {
  66. ...process.env,
  67. RELEASE_TAG: `scriptor-v${version}`,
  68. SCRIPTOR_PUBLISHER: fixturePaths.publisher,
  69. SCRIPTOR_STUB_NPM: fixturePaths.stub,
  70. ...(keepState ? { SCRIPTOR_KEEP_STATE: '1' } : {}),
  71. ...environment,
  72. },
  73. })
  74. assert.equal(result.status, 0, `publish-path fixture failed: ${result.stdout}${result.stderr}`)
  75. const report = JSON.parse(result.stdout.trim().split('\n').pop())
  76. const invocations = fs.readFileSync(path.join(fixture, 'invocations.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
  77. return { report, invocations }
  78. }
  79. function withFixture(callback) {
  80. const base = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptor-publish-path-'))
  81. const assets = path.join(base, 'assets')
  82. const fixture = path.join(base, 'fixture')
  83. fs.mkdirSync(assets)
  84. const { manifest, packages } = writeAssets(assets)
  85. try {
  86. return callback({ base, assets, fixture, manifest, packages })
  87. } finally {
  88. fs.rmSync(base, { recursive: true, force: true })
  89. }
  90. }
  91. test('publish path preflights every package, then uploads the same tarballs in dependency order with provenance', () => withFixture(({ assets, fixture, packages }) => {
  92. const { invocations } = runPublisher({ assets, fixture })
  93. const dryRuns = invocations.filter(item => item.dryRun)
  94. const uploads = invocations.filter(item => !item.dryRun)
  95. assert.equal(dryRuns.length, 3, 'every package must be preflighted before any upload')
  96. assert.deepEqual(uploads.map(item => item.name), packages.map(item => item.name))
  97. for (const item of invocations) {
  98. assert.equal(item.command, 'publish')
  99. assert.ok(item.rest.includes('--access'), 'public access flag is required')
  100. assert.equal(item.rest[item.rest.indexOf('--access') + 1], 'public')
  101. assert.ok(item.rest.includes('--ignore-scripts'), 'lifecycle scripts must stay disabled')
  102. assert.equal(item.rest[item.rest.indexOf('--tag') + 1], 'preview', 'preview releases must not take latest')
  103. const target = item.rest.find(value => value.startsWith('--registry='))
  104. assert.ok(target, 'every npm invocation must name its registry explicitly')
  105. assert.ok(target === '--registry=https://registry.npmjs.org/' || /^--registry=http:\/\/127\.0\.0\.1:\d+\/$/.test(target), `unexpected registry target: ${target}`)
  106. assert.equal(item.registryConfigured, target.slice('--registry='.length), 'the stub must receive the same registry the publisher targets')
  107. }
  108. for (const item of uploads) assert.ok(item.provenance, `provenance is required for ${item.name}`)
  109. for (const item of dryRuns) assert.ok(!item.provenance, 'dry runs must not request provenance')
  110. for (const item of uploads) assert.equal(path.basename(item.rest[0]), item.tarball, 'uploads must name the release tarball')
  111. assert.deepEqual(uploads.map(item => path.basename(item.rest[0])), packages.map(item => item.tarball))
  112. const published = JSON.parse(fs.readFileSync(path.join(fixture, 'published.json'), 'utf8'))
  113. assert.deepEqual(Object.keys(published).sort(), packages.map(item => item.name).sort())
  114. for (const item of packages) assert.equal(published[item.name].tarball, item.tarball)
  115. for (const item of packages) assert.equal(published[item.name].distTag, 'preview')
  116. assert.ok(!Object.values(published).some(item => item.distTag === 'latest'), 'no preview upload may move latest')
  117. assert.deepEqual(uploads.slice(-1).map(item => item.integrity), [published[packages[2].name].integrity])
  118. }))
  119. test('publish retry skips a byte-identical version and never re-uploads it', () => withFixture(({ assets, fixture, packages }) => {
  120. runPublisher({ assets, fixture })
  121. const firstUploads = JSON.parse(fs.readFileSync(path.join(fixture, 'published.json'), 'utf8'))
  122. const { invocations } = runPublisher({ assets, fixture, keepState: true })
  123. const uploads = invocations.filter(item => !item.dryRun)
  124. assert.deepEqual(uploads, [], 'a re-run must not publish an existing name/version again')
  125. assert.equal(invocations.filter(item => item.dryRun).length, 3, 'the preflight still runs on a retry')
  126. assert.deepEqual(JSON.parse(fs.readFileSync(path.join(fixture, 'published.json'), 'utf8')), firstUploads)
  127. for (const item of packages) assert.equal(firstUploads[item.name].distTag, 'preview')
  128. }))
  129. test('publish requires the GitHub Actions environment before any upload', () => withFixture(({ assets, fixture }) => {
  130. const runner = spawnSync(process.execPath, [fixturePaths.runner, assets, fixture, '--publish'], {
  131. cwd: root,
  132. encoding: 'utf8',
  133. windowsHide: true,
  134. timeout: 120000,
  135. env: { ...process.env, RELEASE_TAG: `scriptor-v${version}`, SCRIPTOR_PUBLISHER: fixturePaths.publisher, SCRIPTOR_STUB_NPM: fixturePaths.stub, SCRIPTOR_STRIP_GITHUB: '1' },
  136. })
  137. const report = JSON.parse(runner.stdout.trim().split('\n').pop())
  138. assert.equal(runner.status, 0, 'the fixture itself must report its result')
  139. assert.notEqual(report.code, 0, 'publishing outside Actions must fail')
  140. const invocations = fs.readFileSync(path.join(fixture, 'invocations.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
  141. assert.deepEqual(invocations.filter(item => !item.dryRun), [], 'nothing may be uploaded without provenance authority')
  142. }))