feat(ci): [CI-NPM-01] prepare exact verified candidate bundles

This commit is contained in:
Harvey Zhao committed 2026-09-15 16:20:32 +08:00
1 parent 25faf88e80
commit 520727d762
14 files changed
+829 -8

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
import process from 'node:process'
import { prepareRelease } from './release/prepare.ts'
try {
prepareRelease()
}
catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
}
+73
View File
@@ -0,0 +1,73 @@
# Preparing exact npm candidate files
Run from the repository root with the pinned Node and Yarn:
```sh
yarn release:preflight --packages artplayer,artplayer-plugin-chapter
yarn release:bundle --packages artplayer,artplayer-plugin-chapter --tag next
yarn typecheck:release
yarn test:release-bundle
```
`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
has unfinished candidate, review, device and other required gates. Synthetic test
reports are not evidence that an ArtPlayer package is ready to publish.
## Responsibilities
- `../prepare-release.mjs` is the Node entry and failure exit-code boundary.
- `prepare.ts` parses the explicit package batch/tag, requires the canonical
Node/Yarn, and checks clean Git state before both ledger reads. Ignored generated
files may exist; staged, unstaged and untracked source changes are rejected.
- `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.
- `../../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.
The narrow report interfaces describe the fields consumed by preparation. The
original report is preserved in full. The candidate assertion after batch
validation is the only non-null assertion; an absent or invalid candidate must
be rejected before any output directory is created.
## Output and failure behavior
An accepted run creates a fresh `refactor/.cache/npm-bundle-*/` directory with
the existing tarballs, `preflight.json` and `manifest.json`. No existing output is
overwritten. The manifest binds package names, versions, per-package source
commits, source fingerprints, SHA-256, SHA-512 integrity, batch source commit,
toolchain/lock information and the complete preflight report's hash.
The second ledger read must equal the first; candidates and copied files are
checked again. The manifest completion marker is written last. On failure, only
the verified temporary directory is removed. Redirected paths are rejected; if
cleanup also fails, both errors and the retained directory are reported. A
leftover directory without a manifest is not a successful handoff.
Allowed tags are explicitly `next`, `alpha`, `beta`, `rc` and `latest`; there is
no implicit default, and prereleases cannot use `latest`. Registry metadata is
fixed to the public npm registry. Site distributions cannot enter this npm bundle.
These are preparation-tool constraints, not changes to any player package API.
## Trust boundary and remaining workflow work
Every output retains `publicationAuthorized: false`. A manifest and its hashes
provide content binding, not authenticity or publishing permission. CI-03 still
needs trusted workflow/run artifact provenance, registry occupancy checks, exact
tarball publication without lifecycle rebuilding, permission/OIDC configuration,
partial-failure recovery and post-publication readback. Neither this script nor
its tests enables a GitHub workflow, creates a token, or authorizes publication.
The future publisher must validate downloaded contents and fresh release evidence;
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.
`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.
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { buildLedger, localFile } from '../../refactor/scripts/release-ledger.mjs'
interface Candidate {
path: string
version: string
sourceCommit: string
inputFingerprint: string
integrity: string
errors: string[]
}
interface PackageRow {
name: string
version: string
distribution: string
fingerprint: string
status: string
blockers: unknown[]
candidate: Candidate | null
}
export interface LedgerSnapshot {
schemaVersion: number
sourceCommit: string
evidenceComplete: boolean
publicationAuthorized: boolean
toolchain: { node: string, canonicalNode: string, packageManager: string, lock: { sha256: string } }
packages: PackageRow[]
}
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 {
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')
assert.deepEqual(report.packages.map(row => row.name), names, 'Ledger package selection differs')
assert(report.evidenceComplete, 'Release preflight is blocked; candidate bundle was not prepared')
for (const row of report.packages) {
assert.equal(row.status, 'evidence-complete', `${row.name}: incomplete evidence`)
assert.deepEqual(row.blockers, [], `${row.name}: blocking findings`)
assert(['npm', 'renamed-npm', 'recovered-npm'].includes(row.distribution), `${row.name}: site output must not become an npm publication`)
assert(/^artplayer(?:-[a-z0-9]+)*$/.test(row.name), 'Invalid workspace package name')
assert(/^\d+\.\d+\.\d+(?:-[a-z0-9]+(?:[.-][a-z0-9]+)*)?$/i.test(row.version), `${row.name}: invalid prepared version`)
assert(tag !== 'latest' || !row.version.includes('-'), 'Prerelease versions cannot use latest')
assert(row.candidate, `${row.name}: missing candidate`)
assert.deepEqual(row.candidate.errors, [], `${row.name}: invalid candidate`)
assert.equal(row.candidate.version, row.version, `${row.name}: candidate version drift`)
assert.equal(row.candidate.inputFingerprint, row.fingerprint, `${row.name}: stale candidate`)
assert(/^[a-f\d]{40}$/.test(row.candidate.sourceCommit), `${row.name}: missing candidate commit`)
}
}
// inspect is an internal test seam. The CLI always recomputes the repository ledger;
// it does not accept a caller-supplied JSON report as evidence.
export function prepareReleaseBundle(directory: string, names: string[], tag: string, inspect: (directory: string, names: string[]) => LedgerSnapshot = buildLedger) {
assert(names.length && new Set(names).size === names.length && names.every(name => /^artplayer(?:-[a-z0-9]+)*$/.test(name)), 'Select explicit, unique workspace packages')
assert(['next', 'alpha', 'beta', 'rc', 'latest'].includes(tag), 'Select next, alpha, beta, rc or latest explicitly')
directory = fs.realpathSync(directory)
const initial = inspect(directory, names)
checkBatch(initial, names, tag)
const cache = fs.realpathSync(path.join(directory, 'refactor/.cache'))
assert(cache.startsWith(directory + path.sep), 'Bundle cache escapes repository')
const stage = fs.mkdtempSync(path.join(cache, 'npm-bundle-'))
try {
const packages = initial.packages.map((row) => {
const candidate = row.candidate!
const bytes = fs.readFileSync(localFile(directory, candidate.path))
assert.equal(integrity(bytes), candidate.integrity, `${row.name}: candidate changed after preflight`)
const file = `${row.name}-${row.version}.tgz`
const output = path.join(stage, file)
fs.writeFileSync(output, bytes, { flag: 'wx' })
assert.equal(sha256(fs.readFileSync(output)), sha256(bytes), `${row.name}: copied artifact differs`)
return { name: row.name, version: row.version, file, bytes: bytes.length, sha256: sha256(bytes), integrity: candidate.integrity, sourceCommit: candidate.sourceCommit, inputFingerprint: row.fingerprint }
})
const final = inspect(directory, names)
checkBatch(final, names, tag)
assert.deepEqual(final, initial, 'Release inputs or evidence changed while preparing the bundle')
for (const item of packages)
assert.equal(sha256(fs.readFileSync(path.join(stage, item.file))), item.sha256, `${item.name}: staged artifact changed`)
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.'],
}
// 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 }
}
catch (error) {
try {
const resolved = fs.realpathSync(stage)
assert.equal(path.dirname(resolved), cache, 'Refusing redirected bundle cleanup')
assert(path.basename(resolved).startsWith('npm-bundle-'), 'Refusing unrelated bundle cleanup')
fs.rmSync(resolved, { recursive: true })
}
catch (cleanupError) {
throw new AggregateError([error, cleanupError], `Bundle preparation failed; inspect retained files at ${stage}`)
}
throw error
}
}
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
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 { prepareReleaseBundle } from './bundle.ts'
export function assertCleanSource(directory: string): void {
const status = execFileSync('git', ['status', '--porcelain', '-z', '--untracked-files=normal'], { cwd: directory, encoding: 'utf8' })
assert(status.length === 0, 'Commit source and evidence changes before preparing a release bundle')
}
export function prepareRelease(args = process.argv.slice(2)): void {
const { values } = parseArgs({ args, options: { packages: { type: 'string' }, tag: { type: 'string' } }, strict: true })
assert(values.packages && values.tag, 'Explicit --packages and --tag are required')
const expectedNode = fs.readFileSync(path.join(root, '.node-version'), 'utf8').trim()
assert.equal(process.version, `v${expectedNode}`, 'Use the canonical Node version')
assert(process.env.npm_config_user_agent?.startsWith('yarn/1.22.22 '), 'Use yarn release:bundle')
function inspect(directory: string, names: string[]) {
assertCleanSource(directory)
return buildLedger(directory, names)
}
const result = prepareReleaseBundle(root, values.packages.split(','), values.tag, inspect)
console.log(JSON.stringify({ directory: result.directory, packages: result.manifest.packages.map(({ name, version, integrity }) => ({ name, version, integrity })), publicationAuthorized: false }))
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"types": ["node"],
"allowImportingTsExtensions": true
},
"include": ["release/**/*.ts"]
}