mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-16 17:46:17 -08:00
feat(ci): [CI-NPM-02] verify downloaded bundles against fresh release evidence
This commit is contained in:
1 parent
2983cb7c51
commit
6f7fd7931d
12 files changed
+560
-19
No files matched your search
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user