mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-16 09:36:16 -08:00
feat(ci): [CI-NPM-01] prepare exact verified candidate bundles
This commit is contained in:
1 parent
25faf88e80
commit
520727d762
14 files changed
+829
-8
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["release/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user