feat(ci): [CI-NPM-02] verify downloaded bundles against fresh release evidence

This commit is contained in:
Harvey Zhao committed 2026-09-16 00:59:42 +08:00
1 parent 2983cb7c51
commit 6f7fd7931d
12 files changed
+560 -19

No files matched your search

+42
View File
@@ -9,6 +9,17 @@ yarn typecheck:release
yarn test:release-bundle
```
For a downloaded copy of an already prepared bundle, use:
```sh
yarn release:verify-bundle --directory refactor/.cache/downloaded-bundle --packages artplayer,artplayer-plugin-chapter --tag next --source-commit <expected-40-hex-commit> --manifest-sha256 <independent-64-hex-digest>
```
Get the expected commit, batch, tag and manifest digest from the trusted handoff,
not from the downloaded bundle itself. Passing a digest copied from an untrusted
manifest does not establish authenticity. The workflow that establishes this
handoff is still CI-03 work; this command only verifies local contents and gates.
`release:bundle` does not build, pack, install, contact npm or publish. It is a
handoff step for candidates already recorded in `refactor/release-ledger.json`.
Both commands currently refuse release acceptance because the repository still
@@ -24,6 +35,15 @@ reports are not evidence that an ArtPlayer package is ready to publish.
- `bundle.ts` consumes the repository ledger, checks the batch, copies exact
tarball bytes and writes a hash-bound manifest. Its injected inspector is an
internal test seam; the CLI never trusts a supplied JSON report as approval.
- `bundle.ts` also owns the shared batch rules and manifest description, so
preparation and verification agree on the same versioned handoff format.
- `verify.ts` compares every downloaded file with independent expectations and
two fresh ledger reads. It checks registered candidate bytes and SHA-512,
strict manifest metadata, preflight contents and the exact file roster, then
rechecks the downloaded files before returning. It performs no writes.
- `../verify-release.mjs` is the verification CLI. It requires explicit arguments,
canonical Node/Yarn and clean Git state on both fresh ledger reads. A downloaded
JSON report cannot be injected as the inspector through the CLI.
- `../../refactor/scripts/release-ledger.mjs` remains the source of candidate,
input fingerprint, review, risk, source/license, device, rollback and CI checks.
Do not introduce another manually maintained green-status list here.
@@ -65,9 +85,31 @@ The future publisher must validate downloaded contents and fresh release evidenc
it must not trust an artifact solely because it has this manifest shape. Do not
add a rebuild fallback to preparation or publishing when an artifact is missing.
CI-NPM-02 supplies that content-validation step. A successful verification still
returns `workflowProvenanceVerified: false`, `registryOccupancyVerified: false`
and `publicationAuthorized: false`. It neither contacts GitHub/npm nor executes
package lifecycle scripts. CI-03 must separately verify the repository/workflow,
run, attempt, source commit and artifact identity using trusted remote metadata,
and check registry occupancy immediately before an authorized publication.
The verifier does not preserve a publishable approval across later file changes;
the future publisher must revalidate and consume those same bytes at use time.
Restore the registered candidate files and required evidence to their ledger
locations before verification. The downloaded preflight must equal the newly
computed report, including toolchain and file hashes. A different environment,
missing ignored evidence, changed files or new blockers can therefore reject
an otherwise intact historical download. Keep and diagnose that rejection;
do not edit the downloaded report to make it pass or rebuild a replacement.
Extra files, directories, symlinks/junctions and redirected parent paths are
rejected. Failed verification leaves the downloaded evidence intact.
`release-bundle.test.mjs` uses small real tar archives with explicitly synthetic
ledger reports for byte-copy/failure tests, and temporary real Git repositories
for the clean-source guard. It covers stale inputs, changed outputs, missing
gates, site confusion, selection/tag errors, outside paths/junctions, partial
cleanup and original exception retention. These tests run in `test:baseline`;
strict TypeScript checking runs in `ci:check`, and the TS source is in root lint.
`release-verify.test.mjs` additionally tests downloaded copies, self-consistent
manifest/preflight forgeries, stale fresh gates, changed registered/downloaded
tarballs, missing/extra members, redirected paths and mid-verification changes.
Successful synthetic tests are not actual ArtPlayer candidate acceptance.
+28 -13
View File
@@ -33,11 +33,37 @@ export interface LedgerSnapshot {
packages: PackageRow[]
}
interface PackedCandidate {
name: string
version: string
file: string
bytes: number
sha256: string
integrity: string
sourceCommit: string
inputFingerprint: string
}
export function bundleManifest(report: LedgerSnapshot, tag: string, preflightSha256: string, packages: PackedCandidate[]) {
return {
schemaVersion: 1,
kind: 'artplayer-npm-bundle',
publicationAuthorized: false,
sourceCommit: report.sourceCommit,
registry: 'https://registry.npmjs.org/',
tag,
toolchain: report.toolchain,
preflight: { file: 'preflight.json', sha256: preflightSha256 },
packages,
limitations: ['This bundle is not publication authorization.', 'A future publisher must verify trusted workflow/run provenance, current registry occupancy, all bundle hashes and fresh release evidence before publishing these exact tarballs.'],
}
}
const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
const integrity = (bytes: Uint8Array) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`
const encode = (value: unknown) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`)
function checkBatch(report: LedgerSnapshot, names: string[], tag: string): void {
export function checkBatch(report: LedgerSnapshot, names: string[], tag: string): void {
assert.equal(report.schemaVersion, 1, 'Unsupported release ledger report')
assert.equal(report.publicationAuthorized, false, 'A ledger report cannot authorize publication')
assert(/^[a-f\d]{40}$/.test(report.sourceCommit), 'Missing source commit')
@@ -88,18 +114,7 @@ export function prepareReleaseBundle(directory: string, names: string[], tag: st
const preflight = encode(final)
fs.writeFileSync(path.join(stage, 'preflight.json'), preflight, { flag: 'wx' })
assert.equal(sha256(fs.readFileSync(path.join(stage, 'preflight.json'))), sha256(preflight), 'Written preflight report differs')
const manifest = {
schemaVersion: 1,
kind: 'artplayer-npm-bundle',
publicationAuthorized: false,
sourceCommit: final.sourceCommit,
registry: 'https://registry.npmjs.org/',
tag,
toolchain: final.toolchain,
preflight: { file: 'preflight.json', sha256: sha256(preflight) },
packages,
limitations: ['This bundle is not publication authorization.', 'A future publisher must verify trusted workflow/run provenance, current registry occupancy, all bundle hashes and fresh release evidence before publishing these exact tarballs.'],
}
const manifest = bundleManifest(final, tag, sha256(preflight), packages)
// Completion marker is written last; no directory is returned on failure.
fs.writeFileSync(path.join(stage, 'manifest.json'), encode(manifest), { flag: 'wx' })
return { directory: stage, manifest }
+67
View File
@@ -0,0 +1,67 @@
import type { LedgerSnapshot } from './bundle.ts'
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { buildLedger, localFile } from '../../refactor/scripts/release-ledger.mjs'
import { bundleManifest, checkBatch } from './bundle.ts'
interface BundleExpectation {
sourceCommit: string
manifestSha256: string
names: string[]
tag: string
}
const digest = (bytes: Uint8Array, algorithm = 'sha256', encoding: 'hex' | 'base64' = 'hex') => createHash(algorithm).update(bytes).digest(encoding)
function readFile(directory: string, name: string) {
assert(name && path.basename(name) === name && !/[\\/:]/.test(name) && name !== '.' && name !== '..', 'Bundle member must be a single filename')
const file = path.join(directory, name)
assert(fs.lstatSync(file).isFile(), `Bundle member is not a regular file: ${name}`)
assert.equal(fs.realpathSync(file), file, `Redirected bundle member: ${name}`)
return fs.readFileSync(file)
}
// The CLI computes inspect from the current repository. A downloaded report is
// never used to decide whether the current candidate's release gates have passed.
export function verifyReleaseBundle(repository: string, directory: string, expected: BundleExpectation, inspect: (repository: string, names: string[]) => LedgerSnapshot = buildLedger) {
assert(/^[a-f\d]{40}$/.test(expected.sourceCommit), 'Supply the independently expected source commit')
assert(/^[a-f\d]{64}$/.test(expected.manifestSha256), 'Supply the independently expected manifest SHA-256')
assert(expected.names.length && new Set(expected.names).size === expected.names.length && expected.names.every(name => /^artplayer(?:-[a-z0-9]+)*$/.test(name)), 'Select explicit, unique workspace packages')
assert(['next', 'alpha', 'beta', 'rc', 'latest'].includes(expected.tag), 'Select an explicit supported tag')
repository = fs.realpathSync(repository)
directory = path.resolve(directory)
assert(fs.lstatSync(directory).isDirectory(), 'Bundle path is not a regular directory')
assert.equal(fs.realpathSync(directory), directory, 'Redirected bundle directory')
const initialFiles = fs.readdirSync(directory).sort()
const bytes = readFile(directory, 'manifest.json')
assert.equal(digest(bytes), expected.manifestSha256, 'Bundle manifest differs from the independent digest')
// JSON stays untrusted until exact comparison with a freshly computed manifest.
const manifest = JSON.parse(bytes.toString('utf8')) as Record<string, unknown>
const fresh = inspect(repository, expected.names)
checkBatch(fresh, expected.names, expected.tag)
assert.equal(fresh.sourceCommit, expected.sourceCommit, 'Current source differs from the expected commit')
const preflight = readFile(directory, 'preflight.json')
assert.deepEqual(JSON.parse(preflight.toString('utf8')), fresh, 'Downloaded preflight differs from the fresh repository ledger')
const packages = fresh.packages.map((row) => {
const candidate = row.candidate!
const file = `${row.name}-${row.version}.tgz`
const packed = readFile(directory, file)
const registered = fs.readFileSync(localFile(repository, candidate.path))
assert.deepEqual(packed, registered, `${row.name}: downloaded tarball differs from registered candidate`)
assert.equal(`sha512-${digest(packed, 'sha512', 'base64')}`, candidate.integrity, `${row.name}: candidate integrity changed`)
return { name: row.name, version: row.version, file, bytes: packed.length, sha256: digest(packed), integrity: candidate.integrity, sourceCommit: candidate.sourceCommit, inputFingerprint: row.fingerprint }
})
assert.deepEqual(manifest, bundleManifest(fresh, expected.tag, digest(preflight), packages), 'Bundle metadata differs from the freshly verified candidate batch')
assert.deepEqual(initialFiles, ['manifest.json', 'preflight.json', ...packages.map(pkg => pkg.file)].sort(), 'Unexpected or missing bundle files')
const final = inspect(repository, expected.names)
checkBatch(final, expected.names, expected.tag)
assert.deepEqual(final, fresh, 'Release inputs or evidence changed during verification')
assert.deepEqual(fs.readdirSync(directory).sort(), initialFiles, 'Bundle membership changed during verification')
assert.deepEqual(readFile(directory, 'manifest.json'), bytes, 'Bundle manifest changed during verification')
assert.deepEqual(readFile(directory, 'preflight.json'), preflight, 'Bundle preflight changed during verification')
for (const item of packages)
assert.equal(digest(readFile(directory, item.file)), item.sha256, `${item.name}: bundle changed during verification`)
return { sourceCommit: fresh.sourceCommit, manifestSha256: expected.manifestSha256, packages, contentVerified: true, workflowProvenanceVerified: false, registryOccupancyVerified: false, publicationAuthorized: false }
}
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { parseArgs } from 'node:util'
import { buildLedger, root } from '../refactor/scripts/release-ledger.mjs'
import { assertCleanSource } from './release/prepare.ts'
import { verifyReleaseBundle } from './release/verify.ts'
try {
const { values } = parseArgs({ options: { 'directory': { type: 'string' }, 'packages': { type: 'string' }, 'tag': { type: 'string' }, 'source-commit': { type: 'string' }, 'manifest-sha256': { type: 'string' } }, strict: true })
assert(values.directory && values.packages && values.tag && values['source-commit'] && values['manifest-sha256'], 'Explicit --directory, --packages, --tag, --source-commit and --manifest-sha256 are required')
assert.equal(process.version, `v${fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim()}`, 'Use the canonical Node version')
assert(process.env.npm_config_user_agent?.startsWith('yarn/1.22.22 '), 'Use yarn release:verify-bundle')
const result = verifyReleaseBundle(root, values.directory, { names: values.packages.split(','), tag: values.tag, sourceCommit: values['source-commit'], manifestSha256: values['manifest-sha256'] }, (directory, names) => {
assertCleanSource(directory)
return buildLedger(directory, names)
})
console.log(JSON.stringify(result))
}
catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
}