Ver Fonte

test(release): 端到端验证发布路径的顺序、provenance 与重跑跳过

发布器此前只有单元级校验(资产、清单、重试策略),实际上传的参数与顺序没有自动化证据。新增夹具:临时 registry 记录已发布版本,桩 npm 逐条记录 argv,真实发布器在其中运行。

覆盖:先对三个包全量 dry-run 再上传;上传顺序为 主包 → 嵌入包 → 完整版;每次上传都带 --access public、--tag preview、--ignore-scripts、--provenance,且只指向单一 registry;重跑只跳过字节一致的版本且不重复上传;缺少 GITHUB_ACTIONS 时在上传前拒绝。

为可测性,发布器的 registry 与 npm CLI 位置支持环境变量覆盖(默认值不变:官方 registry 与宿主自带 npm)。
lingfengQAQ há 3 semanas atrás
pai
commit
db908195ac

+ 4 - 2
scripts/release/publish-npm.mjs

@@ -6,7 +6,9 @@ import { execFileSync } from 'node:child_process'
 import { pathToFileURL } from 'node:url'
 import { packageFiles, checkPublishableManifest, checkEmbeddingPackage, checkMetaPackage } from './tar.mjs'
 
-const registry = 'https://registry.npmjs.org/'
+// The official registry is the only release target. The environment override exists so
+// the publish path itself can be exercised against a first-publication fixture.
+const registry = process.env.SCRIPTOR_NPM_REGISTRY ?? 'https://registry.npmjs.org/'
 const mainName = '@linfengqaqtat/dsh-scriptor'
 const embeddingName = 'webnovel-embedding-provider'
 const metaName = '@linfengqaqtat/dsh-scriptor-full'
@@ -117,7 +119,7 @@ async function main() {
   const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8', windowsHide: true }).trim()
   const packages = validateReleaseAssets(directory, process.env.RELEASE_TAG, commit)
   if (args.includes('--verify-only')) return verifyRegistry(packages)
-  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))
+  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))
   assert.ok(npm, 'npm-cli.js was not found; set NPM_CLI_ENTRY')
   const runNpm = rest => execFileSync(process.execPath, [npm, ...rest], { stdio: 'inherit', windowsHide: true })
   const plan = []

+ 87 - 0
scripts/release/tests/fixtures/publish-path-run.mjs

@@ -0,0 +1,87 @@
+/**
+ * End-to-end publish-path fixture: runs the real publisher against a stub npm CLI
+ * and a first-publication registry, so the exact argv, the package order and the
+ * retry behaviour are observed instead of assumed.
+ *
+ * Usage: node publish-path-run.mjs <assets-directory> <work-directory> [--publish|--verify-only]
+ */
+import fs from 'node:fs'
+import path from 'node:path'
+import { spawn } from 'node:child_process'
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+
+const assets = path.resolve(process.argv[2])
+const work = path.resolve(process.argv[3])
+const mode = process.argv[4] ?? '--publish'
+// The caller decides whether an earlier run's registry state is kept, so a retry
+// sees the versions an earlier publish created.
+if (process.env.SCRIPTOR_KEEP_STATE !== '1' && fs.existsSync(work)) fs.rmSync(work, { recursive: true, force: true })
+fs.mkdirSync(work, { recursive: true })
+const log = path.join(work, 'invocations.jsonl')
+const publishState = path.join(work, 'published.json')
+fs.writeFileSync(log, '')
+
+const readState = () => (fs.existsSync(publishState) ? JSON.parse(fs.readFileSync(publishState, 'utf8')) : {})
+const registry = createServer((request, response) => {
+  if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405); response.end(); return }
+  const name = decodeURIComponent(new URL(request.url, 'http://localhost').pathname.slice(1))
+  const tarball = name.match(/^tarballs\/([A-Za-z0-9][A-Za-z0-9._-]*)$/)
+  if (tarball) {
+    const file = path.join(assets, tarball[1])
+    if (!fs.existsSync(file)) { response.writeHead(404); response.end(); return }
+    response.writeHead(200, { 'content-type': 'application/octet-stream' })
+    response.end(fs.readFileSync(file))
+    return
+  }
+  const entry = readState()[name]
+  if (!entry) { response.writeHead(404); response.end(); return }
+  const now = new Date().toISOString()
+  response.writeHead(200, { 'content-type': 'application/json' })
+  response.end(JSON.stringify({ name, 'dist-tags': { [entry.distTag]: entry.version },
+    time: { created: now, modified: now, [entry.version]: now },
+    versions: { [entry.version]: { name, version: entry.version,
+      dist: { tarball: `http://127.0.0.1:${registry.address().port}/tarballs/${entry.tarball}`, integrity: entry.integrity } } } }))
+})
+
+registry.listen(0, '127.0.0.1', async () => {
+  const origin = `http://127.0.0.1:${registry.address().port}/`
+  try {
+    // Absolute paths come from the caller so the fixture never depends on module-relative resolution.
+    const publisher = process.env.SCRIPTOR_PUBLISHER
+    const stub = process.env.SCRIPTOR_STUB_NPM
+    if (!publisher || !stub || !fs.existsSync(publisher) || !fs.existsSync(stub)) {
+      throw new Error(`publish-path fixture needs existing SCRIPTOR_PUBLISHER and SCRIPTOR_STUB_NPM: ${publisher}, ${stub}`)
+    }
+    const environment = {
+      ...process.env,
+      SCRIPTOR_NPM_REGISTRY: origin,
+      SCRIPTOR_STUB_REGISTRY: origin,
+      SCRIPTOR_STUB_LOG: log,
+      SCRIPTOR_STUB_PUBLISH: publishState,
+      NPM_CLI_ENTRY: stub,
+      GITHUB_ACTIONS: 'true',
+      NODE_AUTH_TOKEN: 'fixture-token',
+    }
+    // The publisher must refuse to upload without the Actions provenance authority.
+    if (environment.SCRIPTOR_STRIP_GITHUB) delete environment.GITHUB_ACTIONS
+    const child = spawn(process.execPath, [publisher, '--assets', assets, ...mode.split(' ')], {
+      env: environment,
+      stdio: ['ignore', 'pipe', 'pipe'],
+      windowsHide: true,
+    })
+    const stdout = []
+    const stderr = []
+    child.stdout.on('data', chunk => stdout.push(chunk))
+    child.stderr.on('data', chunk => stderr.push(chunk))
+    const [code] = await once(child, 'exit')
+    fs.writeFileSync(path.join(work, 'publisher.out'), Buffer.concat(stdout).toString('utf8'))
+    fs.writeFileSync(path.join(work, 'publisher.err'), Buffer.concat(stderr).toString('utf8'))
+    console.log(JSON.stringify({ code, origin, work }))
+  } catch (error) {
+    console.error(error.message)
+    process.exitCode = 1
+  } finally {
+    registry.close()
+  }
+})

+ 48 - 0
scripts/release/tests/fixtures/stub-npm.mjs

@@ -0,0 +1,48 @@
+/**
+ * Stub npm CLI for the publish-path fixture. Records every invocation verbatim,
+ * fails loudly on a missing tarball, and registers published names with the
+ * fixture registry so the publisher's own verification step is exercised too.
+ */
+import fs from 'node:fs'
+import path from 'node:path'
+import { createHash } from 'node:crypto'
+import { packageFiles } from '../../tar.mjs'
+
+const [command, ...rest] = process.argv.slice(2)
+const log = process.env.SCRIPTOR_STUB_LOG
+const registry = process.env.SCRIPTOR_STUB_REGISTRY
+const publish = process.env.SCRIPTOR_STUB_PUBLISH
+if (!log || !registry || !publish) {
+  console.error('stub npm: SCRIPTOR_STUB_LOG, SCRIPTOR_STUB_REGISTRY and SCRIPTOR_STUB_PUBLISH are required')
+  process.exit(2)
+}
+const record = value => fs.appendFileSync(log, `${JSON.stringify(value)}\n`)
+
+if (command !== 'publish') {
+  record({ command, rest })
+  console.error(`stub npm: unexpected command ${command}`)
+  process.exit(2)
+}
+const filename = path.resolve(rest[0])
+if (!fs.existsSync(filename)) {
+  record({ command, rest, missing: filename })
+  console.error(`stub npm: tarball does not exist: ${filename}`)
+  process.exit(2)
+}
+const bytes = fs.readFileSync(filename)
+const manifest = JSON.parse(packageFiles(filename).get('package.json'))
+const dryRun = rest.includes('--dry-run')
+const entry = {
+  name: manifest.name,
+  version: manifest.version,
+  tarball: path.basename(filename),
+  integrity: `sha512-${createHash('sha512').update(bytes).digest('base64')}`,
+  distTag: rest[rest.indexOf('--tag') + 1],
+  provenance: rest.includes('--provenance'),
+}
+const state = JSON.parse(fs.existsSync(publish) ? fs.readFileSync(publish, 'utf8') : '{}')
+if (!dryRun) state[manifest.name] = entry
+fs.writeFileSync(publish, JSON.stringify(state, null, 2) + '\n')
+record({ command, rest, dryRun, name: manifest.name, version: manifest.version, tarball: entry.tarball,
+  integrity: entry.integrity, provenance: entry.provenance, distTag: entry.distTag, registryConfigured: registry })
+console.log(`[stub npm] ${dryRun ? 'dry-run ' : ''}publish ${manifest.name}@${manifest.version}`)

+ 150 - 0
scripts/release/tests/publish-path.test.mjs

@@ -0,0 +1,150 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import zlib from 'node:zlib'
+import { createHash } from 'node:crypto'
+import { execFileSync, spawnSync } from 'node:child_process'
+import { root } from '../version.mjs'
+
+const hash = bytes => createHash('sha256').update(bytes).digest('hex')
+const version = '0.1.0-preview.4'
+const publishConfig = { access: 'public', tag: 'preview', registry: 'https://registry.npmjs.org/' }
+function archive(files) {
+  const rows = Object.entries(files).map(([name, text]) => {
+    const body = Buffer.from(typeof text === 'object' ? JSON.stringify(text) : text)
+    const header = Buffer.alloc(512)
+    header.write(`package/${name}`)
+    header.write(body.length.toString(8).padStart(11, '0'), 124)
+    header.write('0', 156)
+    return Buffer.concat([header, body, Buffer.alloc((512 - body.length % 512) % 512)])
+  })
+  return zlib.gzipSync(Buffer.concat([...rows, Buffer.alloc(1024)]))
+}
+const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', windowsHide: true }).trim()
+
+/** Build release assets whose bytes and manifest match what the publisher validates. */
+function writeAssets(directory) {
+  const entries = [
+    ['@linfengqaqtat/dsh-scriptor', version, `linfengqaqtat-dsh-scriptor-${version}.tgz`],
+    ['webnovel-embedding-provider', '0.0.8', 'webnovel-embedding-provider-0.0.8.tgz'],
+    ['@linfengqaqtat/dsh-scriptor-full', version, `linfengqaqtat-dsh-scriptor-full-${version}.tgz`],
+  ]
+  const packages = entries.map(([name, v, tarball]) => ({ name, version: v, tarball }))
+  for (const [index, item] of packages.entries()) {
+    const manifest = { name: item.name, version: item.version, license: 'GPL-3.0-only', publishConfig,
+      repository: { url: 'https://github.com/lingfengQAQ/webnovel-writer.git' } }
+    const files = { 'package.json': manifest, LICENSE: 'GNU GENERAL PUBLIC LICENSE', 'README.md': 'Example' }
+    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' })
+    if (index === 2) {
+      manifest.dependencies = { '@linfengqaqtat/dsh-scriptor': version, 'webnovel-embedding-provider': '0.0.8' }
+      manifest.dsh = { bundle: { patch: './cordis.patch.yml' } }
+      files['cordis.patch.yml'] = fs.readFileSync(path.join(root, 'packages/meta/cordis.patch.yml'), 'utf8')
+    }
+    fs.writeFileSync(path.join(directory, item.tarball), archive(files))
+  }
+  const manifest = { schemaVersion: 1, packageName: packages[0].name, version, tag: `scriptor-v${version}`, publicCommit: commit, prerelease: true,
+    filename: packages[0].tarball, optionalPackages: packages.slice(1),
+    assets: packages.map(item => ({ file: item.tarball, sha256: hash(fs.readFileSync(path.join(directory, item.tarball))) })) }
+  fs.writeFileSync(path.join(directory, 'release-manifest.json'), JSON.stringify(manifest))
+  const files = [...manifest.assets, { file: 'release-manifest.json', sha256: hash(fs.readFileSync(path.join(directory, 'release-manifest.json'))) }]
+  fs.writeFileSync(path.join(directory, 'SHA256SUMS'), files.map(item => `${item.sha256}  ${item.file}`).join('\n') + '\n')
+  return { manifest, packages }
+}
+
+const fixturePaths = {
+  runner: path.join(root, 'scripts/release/tests/fixtures/publish-path-run.mjs'),
+  publisher: path.join(root, 'scripts/release/publish-npm.mjs'),
+  stub: path.join(root, 'scripts/release/tests/fixtures/stub-npm.mjs'),
+}
+
+/** Run the real publisher against the stub npm CLI and a first-publication registry. */
+function runPublisher({ assets, fixture, mode = '--publish', environment = {}, keepState = false }) {
+  const result = spawnSync(process.execPath, [fixturePaths.runner, assets, fixture, ...mode.split(' ')], {
+    cwd: root,
+    encoding: 'utf8',
+    windowsHide: true,
+    timeout: 120000,
+    env: {
+      ...process.env,
+      RELEASE_TAG: `scriptor-v${version}`,
+      SCRIPTOR_PUBLISHER: fixturePaths.publisher,
+      SCRIPTOR_STUB_NPM: fixturePaths.stub,
+      ...(keepState ? { SCRIPTOR_KEEP_STATE: '1' } : {}),
+      ...environment,
+    },
+  })
+  assert.equal(result.status, 0, `publish-path fixture failed: ${result.stdout}${result.stderr}`)
+  const report = JSON.parse(result.stdout.trim().split('\n').pop())
+  const invocations = fs.readFileSync(path.join(fixture, 'invocations.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
+  return { report, invocations }
+}
+
+function withFixture(callback) {
+  const base = fs.mkdtempSync(path.join(os.tmpdir(), 'scriptor-publish-path-'))
+  const assets = path.join(base, 'assets')
+  const fixture = path.join(base, 'fixture')
+  fs.mkdirSync(assets)
+  const { manifest, packages } = writeAssets(assets)
+  try {
+    return callback({ base, assets, fixture, manifest, packages })
+  } finally {
+    fs.rmSync(base, { recursive: true, force: true })
+  }
+}
+
+test('publish path preflights every package, then uploads the same tarballs in dependency order with provenance', () => withFixture(({ assets, fixture, packages }) => {
+  const { invocations } = runPublisher({ assets, fixture })
+  const dryRuns = invocations.filter(item => item.dryRun)
+  const uploads = invocations.filter(item => !item.dryRun)
+  assert.equal(dryRuns.length, 3, 'every package must be preflighted before any upload')
+  assert.deepEqual(uploads.map(item => item.name), packages.map(item => item.name))
+  for (const item of invocations) {
+    assert.equal(item.command, 'publish')
+    assert.ok(item.rest.includes('--access'), 'public access flag is required')
+    assert.equal(item.rest[item.rest.indexOf('--access') + 1], 'public')
+    assert.ok(item.rest.includes('--ignore-scripts'), 'lifecycle scripts must stay disabled')
+    assert.equal(item.rest[item.rest.indexOf('--tag') + 1], 'preview', 'preview releases must not take latest')
+    const target = item.rest.find(value => value.startsWith('--registry='))
+    assert.ok(target, 'every npm invocation must name its registry explicitly')
+    assert.ok(target === '--registry=https://registry.npmjs.org/' || /^--registry=http:\/\/127\.0\.0\.1:\d+\/$/.test(target), `unexpected registry target: ${target}`)
+    assert.equal(item.registryConfigured, target.slice('--registry='.length), 'the stub must receive the same registry the publisher targets')
+  }
+  for (const item of uploads) assert.ok(item.provenance, `provenance is required for ${item.name}`)
+  for (const item of dryRuns) assert.ok(!item.provenance, 'dry runs must not request provenance')
+  for (const item of uploads) assert.equal(path.basename(item.rest[0]), item.tarball, 'uploads must name the release tarball')
+  assert.deepEqual(uploads.map(item => path.basename(item.rest[0])), packages.map(item => item.tarball))
+  const published = JSON.parse(fs.readFileSync(path.join(fixture, 'published.json'), 'utf8'))
+  assert.deepEqual(Object.keys(published).sort(), packages.map(item => item.name).sort())
+  for (const item of packages) assert.equal(published[item.name].tarball, item.tarball)
+  for (const item of packages) assert.equal(published[item.name].distTag, 'preview')
+  assert.ok(!Object.values(published).some(item => item.distTag === 'latest'), 'no preview upload may move latest')
+  assert.deepEqual(uploads.slice(-1).map(item => item.integrity), [published[packages[2].name].integrity])
+}))
+
+test('publish retry skips a byte-identical version and never re-uploads it', () => withFixture(({ assets, fixture, packages }) => {
+  runPublisher({ assets, fixture })
+  const firstUploads = JSON.parse(fs.readFileSync(path.join(fixture, 'published.json'), 'utf8'))
+  const { invocations } = runPublisher({ assets, fixture, keepState: true })
+  const uploads = invocations.filter(item => !item.dryRun)
+  assert.deepEqual(uploads, [], 'a re-run must not publish an existing name/version again')
+  assert.equal(invocations.filter(item => item.dryRun).length, 3, 'the preflight still runs on a retry')
+  assert.deepEqual(JSON.parse(fs.readFileSync(path.join(fixture, 'published.json'), 'utf8')), firstUploads)
+  for (const item of packages) assert.equal(firstUploads[item.name].distTag, 'preview')
+}))
+
+test('publish requires the GitHub Actions environment before any upload', () => withFixture(({ assets, fixture }) => {
+  const runner = spawnSync(process.execPath, [fixturePaths.runner, assets, fixture, '--publish'], {
+    cwd: root,
+    encoding: 'utf8',
+    windowsHide: true,
+    timeout: 120000,
+    env: { ...process.env, RELEASE_TAG: `scriptor-v${version}`, SCRIPTOR_PUBLISHER: fixturePaths.publisher, SCRIPTOR_STUB_NPM: fixturePaths.stub, SCRIPTOR_STRIP_GITHUB: '1' },
+  })
+  const report = JSON.parse(runner.stdout.trim().split('\n').pop())
+  assert.equal(runner.status, 0, 'the fixture itself must report its result')
+  assert.notEqual(report.code, 0, 'publishing outside Actions must fail')
+  const invocations = fs.readFileSync(path.join(fixture, 'invocations.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line))
+  assert.deepEqual(invocations.filter(item => !item.dryRun), [], 'nothing may be uploaded without provenance authority')
+}))