mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-14 16:36:18 -08:00
feat(REL-08): bind package release gates to candidate evidence
This commit is contained in:
1 parent
3316b8a867
commit
04060aa8b6
14 files changed
+3383
-6
No files matched your search
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
export const libraryGates = ['build', 'runtime', 'types', 'combinations', 'browser', 'devices', 'licenses', 'rollback', 'remote-ci', 'review-01', 'review-02', 'review-03']
|
||||
export const siteGates = ['build', 'site-urls', 'site-assets', 'editor', 'devices', 'licenses', 'rollback', 'remote-ci', 'review-01', 'review-02', 'review-03']
|
||||
export const sharedTasks = ['CI-01', 'CI-03', 'CI-04', 'REL-01', 'REL-09', 'REL-02', 'REL-03', 'REL-04', 'REVIEW-01', 'REVIEW-02', 'REVIEW-03']
|
||||
|
||||
export function dependencyClosure(name, names, edges, dynamicImports = []) {
|
||||
if (dynamicImports.length)
|
||||
return [...names].sort()
|
||||
const dependencies = new Set([name])
|
||||
let count = -1
|
||||
while (count !== dependencies.size) {
|
||||
count = dependencies.size
|
||||
for (const edge of edges) {
|
||||
if (dependencies.has(edge.consumer))
|
||||
dependencies.add(edge.dependency)
|
||||
}
|
||||
}
|
||||
return [...dependencies].sort()
|
||||
}
|
||||
|
||||
export function findingPackages(item, tasks, names) {
|
||||
const explicit = new Set(item.owners.flatMap(id => tasks.get(id)?.scope || []).filter(scope => names.includes(scope)))
|
||||
// A generic review owner must not turn a package-specific finding into a global one.
|
||||
return explicit.size ? [...explicit] : [...names]
|
||||
}
|
||||
|
||||
export function validateLedger(ledger, inventory, tasks) {
|
||||
assert.equal(ledger.schemaVersion, 1)
|
||||
const expected = inventory.map(pkg => pkg.name).sort()
|
||||
assert.deepEqual(ledger.packages.map(pkg => pkg.name).sort(), expected, 'Ledger must cover exactly every workspace package once')
|
||||
assert.deepEqual(ledger.libraryGates, libraryGates, 'Missing mandatory library gate')
|
||||
assert.deepEqual(ledger.siteGates, siteGates, 'Missing mandatory site gate')
|
||||
assert.deepEqual(ledger.sharedTasks, sharedTasks, 'Missing mandatory shared release/review task')
|
||||
for (const row of ledger.packages) {
|
||||
const initial = inventory.find(pkg => pkg.name === row.name)
|
||||
assert.equal(row.targetVersion, `${Number(initial.version.split('.')[0]) + 1}.0.0`, `Unexpected next major: ${row.name}`)
|
||||
assert(['npm', 'renamed-npm', 'recovered-npm', 'site'].includes(row.distribution), `Unknown distribution: ${row.name}`)
|
||||
assert.equal(row.distribution === 'site', initial.kind === 'docs', 'A site must not become a new npm publication implicitly')
|
||||
assert(row.history?.file && row.history.rationale, `Missing distribution basis: ${row.name}`)
|
||||
assert(row.rollback?.basis && row.rollback.strategy, `Missing rollback mapping: ${row.name}`)
|
||||
assert(Array.isArray(row.extraRequirements) && row.extraRequirements.length, `Missing capability requirements: ${row.name}`)
|
||||
assert(row.extraRequirements.every(item => typeof item === 'string' && item.trim()), 'Invalid capability requirement')
|
||||
assert(new Set(row.extraRequirements).size === row.extraRequirements.length, 'Duplicate requirement')
|
||||
assert(row.evidence && !Array.isArray(row.evidence) && typeof row.evidence === 'object')
|
||||
assert(Object.keys(row.evidence).every(gate => (row.distribution === 'site' ? siteGates : libraryGates).includes(gate)), 'Unknown evidence gate')
|
||||
assert(row.candidate === null || typeof row.candidate === 'object', 'Invalid candidate')
|
||||
}
|
||||
for (const id of ledger.sharedTasks)
|
||||
assert(tasks.has(id), `Unknown release task: ${id}`)
|
||||
}
|
||||
|
||||
export function evaluatePackage({ row, fingerprint, version, candidate, evidence, taskGaps, risks, assets, history }) {
|
||||
const gates = row.distribution === 'site' ? siteGates : libraryGates
|
||||
const blockers = []
|
||||
const add = (kind, detail) => blockers.push({ kind, detail })
|
||||
if (version !== row.targetVersion)
|
||||
add('version', `${version} must be prepared as ${row.targetVersion} before final candidate validation`)
|
||||
if (!history.verified)
|
||||
add('distribution-history', history.reason)
|
||||
if (!candidate) {
|
||||
add('candidate', 'No candidate artifact is bound')
|
||||
}
|
||||
else {
|
||||
for (const error of candidate.errors)
|
||||
add('candidate', error)
|
||||
if (candidate.inputFingerprint !== fingerprint)
|
||||
add('stale-candidate', 'Source, dependencies, tooling or test inputs changed')
|
||||
if (candidate.version !== version)
|
||||
add('candidate-version', 'Artifact version differs from current manifest')
|
||||
}
|
||||
for (const id of taskGaps) add('task', id)
|
||||
for (const risk of risks.filter(risk => risk.status === 'open')) add('risk', risk.id)
|
||||
const checks = {}
|
||||
for (const gate of gates) {
|
||||
const report = evidence[gate]
|
||||
const errors = []
|
||||
if (!report) {
|
||||
errors.push('Missing candidate-bound evidence')
|
||||
}
|
||||
else {
|
||||
errors.push(...report.errors)
|
||||
if (report.result !== 'pass')
|
||||
errors.push(`Result is ${report.result}`)
|
||||
if (report.package !== row.name || report.gate !== gate)
|
||||
errors.push('Package/gate scope mismatch')
|
||||
if (report.version !== version)
|
||||
errors.push('Evidence version mismatch')
|
||||
if (!candidate || report.candidateIntegrity !== candidate.integrity)
|
||||
errors.push('Candidate integrity mismatch')
|
||||
if (report.inputFingerprint !== fingerprint)
|
||||
errors.push('Evidence input fingerprint is stale')
|
||||
if (!report.checks?.length || report.checks.some(check => check.result !== 'pass'))
|
||||
errors.push('Required checks contain missing, failed, skipped or unknown results')
|
||||
if (!report.environment?.length || !report.command || !report.reviewedBy)
|
||||
errors.push('Missing environment, command or review attribution')
|
||||
if (gate === 'devices' && report.environment?.some(env => env.emulated !== false || !env.device || !env.os || !env.browser))
|
||||
errors.push('Device evidence must explicitly identify real devices, OS and browser')
|
||||
if (gate === 'devices' && report.checks?.some(check => check.mode !== 'native'))
|
||||
errors.push('Device/capability checks cannot use mocks or emulation')
|
||||
if (gate === 'remote-ci' && !report.environment?.some(env => env.remote === true && /^https:\/\/github\.com\/[^/]+\/[^/]+\/actions\/runs\/\d+$/.test(env.runUrl)))
|
||||
errors.push('Remote CI evidence needs an actual GitHub Actions run reference')
|
||||
const required = gate === 'devices' ? row.extraRequirements : gate === 'licenses' ? assets.map(asset => asset.id) : []
|
||||
const covered = new Set(report.checks?.filter(check => check.result === 'pass').map(check => check.id))
|
||||
for (const requirement of required) {
|
||||
if (!covered.has(requirement))
|
||||
errors.push(`Missing coverage: ${requirement}`)
|
||||
}
|
||||
}
|
||||
checks[gate] = errors.length ? { status: 'blocked', errors } : { status: 'evidence-recorded' }
|
||||
for (const error of errors) add(`evidence:${gate}`, error)
|
||||
}
|
||||
return { name: row.name, version, targetVersion: row.targetVersion, distribution: row.distribution, fingerprint, candidate, history, rollback: row.rollback, risks, assets, checks, blockers, status: blockers.length ? 'blocked' : 'evidence-complete', publicationAuthorized: false }
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
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 { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { readImpactModel, repositoryPath } from './impact-model.mjs'
|
||||
import { dependencyClosure, evaluatePackage, findingPackages, validateLedger } from './release-ledger-model.mjs'
|
||||
import { archiveFiles, hash, readMember } from './releases.mjs'
|
||||
|
||||
export const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
|
||||
export function localFile(directory, relative) {
|
||||
const normalized = repositoryPath(relative)
|
||||
const resolved = fs.realpathSync(path.resolve(directory, normalized))
|
||||
assert(resolved.startsWith(fs.realpathSync(directory) + path.sep), `File escapes repository: ${relative}`)
|
||||
assert(fs.statSync(resolved).isFile(), `Not a file: ${relative}`)
|
||||
return resolved
|
||||
}
|
||||
|
||||
function read(directory, relative) {
|
||||
return JSON.parse(fs.readFileSync(localFile(directory, relative), 'utf8'))
|
||||
}
|
||||
|
||||
export function checkedReport(directory, binding) {
|
||||
if (!binding)
|
||||
return null
|
||||
const errors = []
|
||||
try {
|
||||
const bytes = fs.readFileSync(localFile(directory, binding.path))
|
||||
assert.equal(hash(bytes), binding.sha256, 'Report bytes changed')
|
||||
const report = JSON.parse(bytes)
|
||||
assert.equal(report.schemaVersion, 1, 'Unsupported evidence schema')
|
||||
assert(Array.isArray(report.artifacts) && report.artifacts.length, 'No underlying result artifacts')
|
||||
for (const artifact of report.artifacts) {
|
||||
assert.equal(hash(fs.readFileSync(localFile(directory, artifact.path))), artifact.sha256, `Underlying evidence changed: ${artifact.path}`)
|
||||
}
|
||||
return { ...report, errors }
|
||||
}
|
||||
catch (error) {
|
||||
return { errors: [error.message] }
|
||||
}
|
||||
}
|
||||
|
||||
export function checkedCandidate(directory, row) {
|
||||
const binding = row.candidate
|
||||
if (!binding)
|
||||
return null
|
||||
const result = { ...binding, errors: [] }
|
||||
try {
|
||||
assert(/^[a-f\d]{40}$/.test(binding.sourceCommit), 'Missing candidate source commit')
|
||||
execFileSync('git', ['cat-file', '-e', `${binding.sourceCommit}^{commit}`], { cwd: directory, stdio: 'pipe' })
|
||||
const artifact = localFile(directory, binding.path)
|
||||
const bytes = fs.readFileSync(artifact)
|
||||
assert.equal(`sha512-${hash(bytes, 'sha512', 'base64')}`, binding.integrity, 'Candidate integrity mismatch')
|
||||
if (row.distribution === 'site') {
|
||||
assert.equal(binding.kind, 'site-manifest', 'Site requires a build-file manifest, not an inferred npm tarball')
|
||||
const manifest = JSON.parse(bytes)
|
||||
assert.equal(manifest.package, row.name)
|
||||
assert.equal(manifest.version, binding.version)
|
||||
assert(Array.isArray(manifest.files) && manifest.files.length, 'Empty site output')
|
||||
assert.equal(new Set(manifest.files.map(file => file.path)).size, manifest.files.length, 'Duplicate site outputs')
|
||||
const outputRoot = fs.realpathSync(path.resolve(directory, repositoryPath(manifest.outputRoot)))
|
||||
assert(outputRoot.startsWith(fs.realpathSync(directory) + path.sep), 'Site output escapes repository')
|
||||
const walk = folder => fs.readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
|
||||
assert(!entry.isSymbolicLink(), 'Site output contains a redirected file')
|
||||
const child = path.join(folder, entry.name)
|
||||
return entry.isDirectory() ? walk(child) : [path.relative(directory, child).replaceAll('\\', '/')]
|
||||
})
|
||||
assert.deepEqual(walk(outputRoot).sort(), manifest.files.map(file => repositoryPath(file.path)).sort(), 'Site output inventory differs')
|
||||
for (const file of manifest.files)
|
||||
assert.equal(hash(fs.readFileSync(localFile(directory, file.path))), file.sha256, `Site build changed: ${file.path}`)
|
||||
}
|
||||
else {
|
||||
assert.equal(binding.kind, 'npm-tarball')
|
||||
archiveFiles(artifact)
|
||||
const manifest = JSON.parse(readMember(artifact, 'package/package.json'))
|
||||
assert.equal(manifest.name, row.name, 'Tarball package name mismatch')
|
||||
assert.equal(manifest.version, binding.version, 'Tarball version mismatch')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
result.errors.push(error.message)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function historyFor(directory, row) {
|
||||
const data = read(directory, row.history.file)
|
||||
const selected = row.history.selector?.split('.').reduce((object, key) => object?.[key], data)
|
||||
if (row.distribution === 'site')
|
||||
return { verified: true, kind: 'site-only', file: row.history.file, rationale: row.history.rationale, npmPublication: false }
|
||||
if (!selected?.integrity || !selected?.tarball || !selected?.sha256)
|
||||
return { verified: false, reason: 'Historical archive unavailable; recovered content is not a complete rollback tarball', file: row.history.file }
|
||||
const expectedName = row.history.publishedName || row.name
|
||||
assert.equal(selected.name, expectedName, `Wrong historical package: ${row.name}`)
|
||||
return { verified: true, kind: row.distribution, file: row.history.file, name: selected.name, version: selected.version, integrity: selected.integrity, sha256: selected.sha256, tarball: selected.tarball, rationale: row.history.rationale, limitation: 'Frozen historical distribution mapping; not a fresh registry/version-availability check or rollback rehearsal' }
|
||||
}
|
||||
|
||||
export function fingerprintInputs(directory, files, dependencies, site) {
|
||||
const selected = files.filter(file => dependencies.some(name => file.startsWith(`packages/${name}/`))
|
||||
|| /^(?:scripts\/|types\/|test\/|\.github\/workflows\/)/.test(file)
|
||||
|| /^(?:package\.json|yarn\.lock|\.node-version|\.gitattributes|\.yarnrc|\.npmrc|tsconfig[^/]*\.json|playwright[^/]*\.js|eslint\.config\.js|lerna\.json)$/.test(file)
|
||||
|| /^refactor\/(?:third-party\.json|scripts\/release-ledger[^/]*\.mjs)$/.test(file)
|
||||
|| /^refactor\/(?:compatibility\.md|quality-contract\.md|environment-matrix\.md|release-reviews\.md|version-policy\.md|package-inventory\.json|impact-policy\.json)$/.test(file)
|
||||
|| /^refactor\/baselines\/(?:releases|.*-release|.*-sdk(?:-matrix)?|.*-core)\.json$/.test(file)
|
||||
|| (site && /^(?:docs\/|example\/)/.test(file)))
|
||||
return selected.sort().map((file) => {
|
||||
const bytes = fs.readFileSync(localFile(directory, file))
|
||||
const basename = path.basename(file)
|
||||
const text = /\.(?:[cm]?[jt]sx?|vue|json|md|txt|html|css|less|svg|ya?ml|lock)$/.test(file)
|
||||
|| ['.node-version', '.npmignore', '.gitignore', '.yarnrc', '.npmrc', '.gitattributes'].includes(basename)
|
||||
|| /^(?:LICENSE|NOTICE|README)(?:\..*)?$/i.test(basename)
|
||||
return { path: file, algorithm: text ? 'sha256-lf' : 'sha256', sha256: hash(text ? bytes.toString('utf8').replaceAll('\r\n', '\n') : bytes), rawSha256: hash(bytes) }
|
||||
})
|
||||
}
|
||||
|
||||
export function fingerprintOf(inputs) {
|
||||
return hash(JSON.stringify(inputs.map(({ path, algorithm, sha256 }) => ({ path, algorithm, sha256 }))))
|
||||
}
|
||||
|
||||
export function buildLedger(directory = root, selectedNames) {
|
||||
const ledger = read(directory, 'refactor/release-ledger.json')
|
||||
const inventory = read(directory, 'refactor/package-inventory.json').packages
|
||||
const tasks = new Map(read(directory, 'refactor/tasks.json').tasks.map(task => [task.id, task]))
|
||||
validateLedger(ledger, inventory, tasks)
|
||||
const names = inventory.map(pkg => pkg.name)
|
||||
const selected = selectedNames || names
|
||||
assert(selected.length && new Set(selected).size === selected.length && selected.every(name => names.includes(name)), 'Unknown, empty or duplicate batch package selection')
|
||||
const model = readImpactModel(directory)
|
||||
const risks = read(directory, 'refactor/risks.json').items
|
||||
const thirdParty = read(directory, 'refactor/third-party.json')
|
||||
const assets = [...thirdParty.vendored, ...thirdParty.integrations]
|
||||
const files = [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: directory, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }).split('\0').filter(file => file && fs.existsSync(path.join(directory, file))))]
|
||||
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: directory, encoding: 'utf8' }).trim()
|
||||
const inputFiles = {}
|
||||
const rows = selected.map((name) => {
|
||||
const row = ledger.packages.find(pkg => pkg.name === name)
|
||||
const dependencies = dependencyClosure(name, names, model.edges, model.dynamicImports)
|
||||
const applies = item => findingPackages(item, tasks, names).some(pkg => dependencies.includes(pkg))
|
||||
const relevantRisks = risks.filter(applies)
|
||||
const relevantAssets = [
|
||||
...assets.filter(applies),
|
||||
...thirdParty.packages.filter(pkg => dependencies.includes(pkg.name)).flatMap(pkg => pkg.resolvedRuntimeDependencies.map(dependency => ({ id: `dependency:${pkg.name}:${dependency.name}`, sourceStatus: `Locked ${dependency.lockedVersion}; declared ${dependency.range}`, licenseStatus: dependency.licenseEvidence }))),
|
||||
]
|
||||
const inputs = fingerprintInputs(directory, files, dependencies, row.distribution === 'site')
|
||||
Object.assign(inputFiles, Object.fromEntries(inputs.map(({ path, ...input }) => [path, input])))
|
||||
const fingerprint = fingerprintOf(inputs)
|
||||
const taskGaps = [...tasks.values()].filter(task => (ledger.sharedTasks.includes(task.id)
|
||||
|| (task.scope.some(scope => dependencies.includes(scope)) && /^(?:PKG-|CORE-|SITE-|EX-)/.test(task.id))) && task.status !== 'done').map(task => task.id)
|
||||
const evidence = Object.fromEntries(Object.entries(row.evidence).map(([gate, binding]) => [gate, checkedReport(directory, binding)]))
|
||||
const result = evaluatePackage({
|
||||
row,
|
||||
fingerprint,
|
||||
version: read(directory, `packages/${name}/package.json`).version,
|
||||
candidate: checkedCandidate(directory, row),
|
||||
evidence,
|
||||
taskGaps,
|
||||
risks: relevantRisks.map(({ id, status, owners, evidence, compatibleResolution, closureCriteria }) => ({ id, status, owners, evidence, compatibleResolution, closureCriteria })),
|
||||
assets: relevantAssets.map(({ id, riskId, sourceStatus, licenseStatus, validationStatus, reviewEvidence }) => ({ id, riskId, sourceStatus, licenseStatus, validationStatus, reviewEvidence })),
|
||||
history: historyFor(directory, row),
|
||||
})
|
||||
return { ...result, dependencies, inputs: inputs.map(input => input.path), historicalTaskEvidence: [...tasks.values()].filter(task => task.scope.includes(name)).map(({ id, status, evidence }) => ({ id, status, evidence })), historicalEvidenceAcceptedForCandidate: false }
|
||||
})
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
sourceCommit: head,
|
||||
toolchain: {
|
||||
node: process.version,
|
||||
canonicalNode: fs.readFileSync(localFile(directory, '.node-version'), 'utf8').trim(),
|
||||
packageManager: read(directory, 'package.json').packageManager,
|
||||
observedUserAgent: process.env.npm_config_user_agent || null,
|
||||
declaredTools: read(directory, 'package.json').devDependencies,
|
||||
lock: { path: 'yarn.lock', sha256: hash(fs.readFileSync(localFile(directory, 'yarn.lock'))) },
|
||||
},
|
||||
ledger: { path: 'refactor/release-ledger.json', sha256: hash(fs.readFileSync(localFile(directory, 'refactor/release-ledger.json'))) },
|
||||
inputFiles,
|
||||
packages: rows,
|
||||
evidenceComplete: rows.every(row => row.status === 'evidence-complete'),
|
||||
publicationAuthorized: false,
|
||||
limitation: 'Mechanical evidence binding and blocking report. Report contents still require the three reviews; this tool neither executes tests nor verifies devices nor authorizes publishing.',
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
||||
const { values } = parseArgs({ options: { packages: { type: 'string' }, report: { type: 'boolean' }, strict: { type: 'boolean' } } })
|
||||
const result = buildLedger(root, values.packages?.split(','))
|
||||
if (values.strict) {
|
||||
assert.equal(process.version, `v${result.toolchain.canonicalNode}`, 'Strict release preflight requires canonical Node')
|
||||
assert(process.env.npm_config_user_agent?.startsWith('yarn/1.22.22 '), 'Strict release preflight requires Yarn Classic1.22.22')
|
||||
}
|
||||
if (values.report) {
|
||||
const directory = fs.mkdtempSync(path.join(root, 'refactor/.cache/release-ledger-'))
|
||||
fs.writeFileSync(path.join(directory, 'report.json'), `${JSON.stringify(result, null, 2)}\n`)
|
||||
console.log(`Release ledger report: ${directory}`)
|
||||
}
|
||||
console.log(JSON.stringify({ packages: result.packages.map(({ name, status, blockers }) => ({ name, status, blockers: blockers.length })), publicationAuthorized: false }))
|
||||
if (values.strict && !result.evidenceComplete)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Release evidence must fail closed under stale or mis-scoped input.
|
||||
import test from 'node:test'
|
||||
import { dependencyClosure, evaluatePackage, findingPackages, libraryGates, sharedTasks, siteGates, validateLedger } from './release-ledger-model.mjs'
|
||||
import { checkedCandidate, checkedReport, fingerprintInputs, fingerprintOf, localFile, root } from './release-ledger.mjs'
|
||||
import { hash } from './releases.mjs'
|
||||
|
||||
function fixture() {
|
||||
const row = { name: 'example', distribution: 'npm', targetVersion: '2.0.0', extraRequirements: ['cast-session'], rollback: { strategy: 'restore previous tested candidate' } }
|
||||
const candidate = { version: '2.0.0', integrity: 'candidate-digest', inputFingerprint: 'current-inputs', errors: [] }
|
||||
const evidence = Object.fromEntries(libraryGates.map(gate => [gate, { errors: [], result: 'pass', package: 'example', version: '2.0.0', gate, candidateIntegrity: candidate.integrity, inputFingerprint: 'current-inputs', checks: [{ id: gate === 'devices' ? 'cast-session' : 'normal-path', result: 'pass', mode: 'native' }], environment: [{ os: 'device-os', browser: 'browser-version', device: 'physical-model', emulated: false, remote: true, runUrl: 'https://github.com/example/repo/actions/runs/1' }], command: 'actual test command', reviewedBy: 'review record' }]))
|
||||
return { row, fingerprint: 'current-inputs', version: '2.0.0', candidate, evidence, taskGaps: [], risks: [], assets: [], history: { verified: true } }
|
||||
}
|
||||
|
||||
test('Release ledger synthetic complete evidence remains publication-unauthorized', () => {
|
||||
const result = evaluatePackage(fixture())
|
||||
assert.equal(result.status, 'evidence-complete')
|
||||
assert.equal(result.publicationAuthorized, false)
|
||||
})
|
||||
|
||||
for (const [name, mutate, kind] of [
|
||||
['missing candidate', input => input.candidate = null, 'candidate'],
|
||||
['changed sources', input => input.fingerprint = 'changed-source', 'stale-candidate'],
|
||||
['unprepared major', input => input.version = '1.1.0', 'version'],
|
||||
['wrong artifact version', input => input.candidate.version = '9.0.0', 'candidate-version'],
|
||||
['archive bytes differ', input => input.candidate.errors.push('digest differs'), 'candidate'],
|
||||
['missing rollback archive', input => input.history = { verified: false, reason: 'not recovered' }, 'distribution-history'],
|
||||
['open affected risk', input => input.risks.push({ id: 'CAST-01', status: 'open' }), 'risk'],
|
||||
['unfinished review', input => input.taskGaps.push('REVIEW-03'), 'task'],
|
||||
['missing format report', input => delete input.evidence.runtime, 'evidence:runtime'],
|
||||
['wrong package scope', input => input.evidence.runtime.package = 'other-package', 'evidence:runtime'],
|
||||
['wrong gate scope', input => input.evidence.runtime.gate = 'types', 'evidence:runtime'],
|
||||
['wrong report version', input => input.evidence.runtime.version = '1.0.0', 'evidence:runtime'],
|
||||
['old tarball report', input => input.evidence.runtime.candidateIntegrity = 'old-digest', 'evidence:runtime'],
|
||||
['stale type report', input => input.evidence.types.inputFingerprint = 'old-inputs', 'evidence:types'],
|
||||
['skipped native check', input => input.evidence.devices.checks[0].result = 'skipped', 'evidence:devices'],
|
||||
['emulated device', input => input.evidence.devices.environment[0].emulated = true, 'evidence:devices'],
|
||||
['unidentified device', input => delete input.evidence.devices.environment[0].device, 'evidence:devices'],
|
||||
['mock capability on real device', input => input.evidence.devices.checks[0].mode = 'mock', 'evidence:devices'],
|
||||
['local CI as remote proof', input => input.evidence['remote-ci'].environment[0].remote = false, 'evidence:remote-ci'],
|
||||
['missing capability', input => input.evidence.devices.checks[0].id = 'viewport-only', 'evidence:devices'],
|
||||
['missing source/license coverage', input => input.assets.push({ id: 'worker-wasm-font' }), 'evidence:licenses'],
|
||||
['empty successful report', input => input.evidence.browser.checks = [], 'evidence:browser'],
|
||||
]) {
|
||||
test(`Release ledger blocks ${name}`, () => {
|
||||
const input = fixture()
|
||||
mutate(input)
|
||||
const result = evaluatePackage(input)
|
||||
assert.equal(result.status, 'blocked')
|
||||
assert(result.blockers.some(blocker => blocker.kind === kind))
|
||||
})
|
||||
}
|
||||
|
||||
test('Release ledger maps dependency risks without generic review owners tainting unrelated packages', () => {
|
||||
const names = ['core', 'hls', 'cast', 'site']
|
||||
const edges = [{ consumer: 'hls', dependency: 'core' }, { consumer: 'cast', dependency: 'core' }, { consumer: 'site', dependency: 'hls' }]
|
||||
assert.deepEqual(dependencyClosure('cast', names, edges), ['cast', 'core'])
|
||||
assert.deepEqual(dependencyClosure('site', names, edges), ['core', 'hls', 'site'])
|
||||
assert.deepEqual(dependencyClosure('cast', names, edges, ['unknown-import']), [...names].sort())
|
||||
const tasks = new Map([['HLS-01', { scope: ['hls'] }], ['REVIEW-02', { scope: ['workspace'] }]])
|
||||
assert.deepEqual(findingPackages({ owners: ['HLS-01', 'REVIEW-02'] }, tasks, names), ['hls'])
|
||||
assert.deepEqual(findingPackages({ owners: ['REVIEW-02'] }, tasks, names), names)
|
||||
})
|
||||
|
||||
function temp(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-ledger-test-'))
|
||||
t.after(() => {
|
||||
const resolved = fs.realpathSync(directory)
|
||||
assert.equal(path.dirname(resolved), fs.realpathSync(os.tmpdir()))
|
||||
assert(path.basename(resolved).startsWith('artplayer-ledger-test-'))
|
||||
fs.rmSync(resolved, { recursive: true, force: true })
|
||||
})
|
||||
const save = (file, value) => {
|
||||
fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true })
|
||||
fs.writeFileSync(path.join(directory, file), value)
|
||||
}
|
||||
return { directory, save }
|
||||
}
|
||||
|
||||
test('Release ledger hashes actual report and underlying artifacts; replaced bytes cannot pass', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
save('actual.log', 'actual observed output')
|
||||
const report = { schemaVersion: 1, result: 'pass', artifacts: [{ path: 'actual.log', sha256: hash('actual observed output') }] }
|
||||
save('report.json', JSON.stringify(report))
|
||||
const binding = { path: 'report.json', sha256: hash(JSON.stringify(report)) }
|
||||
assert.deepEqual(checkedReport(directory, binding).errors, [])
|
||||
save('actual.log', 'different output')
|
||||
assert.match(checkedReport(directory, binding).errors[0], /Underlying evidence changed/)
|
||||
save('report.json', '{}')
|
||||
assert.match(checkedReport(directory, binding).errors[0], /Report bytes changed/)
|
||||
assert.throws(() => localFile(directory, '../outside'), /escapes/)
|
||||
})
|
||||
|
||||
test('Release input hashing isolates unrelated package edits but invalidates shared lock/test and dependencies', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
const files = ['packages/core/src.js', 'packages/hls/src.js', 'packages/cast/src.js', 'yarn.lock', 'test/shared.js', 'refactor/baselines/hls-sdk-matrix.json', 'refactor/release-reviews.md']
|
||||
files.forEach(file => save(file, 'original'))
|
||||
const fingerprint = () => fingerprintOf(fingerprintInputs(directory, files, ['core', 'cast'], false))
|
||||
const before = fingerprint()
|
||||
save('packages/hls/src.js', 'unrelated')
|
||||
assert.equal(fingerprint(), before)
|
||||
for (const file of ['packages/core/src.js', 'packages/cast/src.js', 'yarn.lock', 'test/shared.js', 'refactor/baselines/hls-sdk-matrix.json', 'refactor/release-reviews.md']) {
|
||||
save(file, 'changed')
|
||||
assert.notEqual(fingerprint(), before)
|
||||
save(file, 'original')
|
||||
}
|
||||
})
|
||||
|
||||
test('Release input fingerprint normalizes text EOL but records raw bytes and preserves binary differences', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
const files = ['packages/core/src.ts', 'packages/core/font.woff2']
|
||||
save(files[0], 'line1\r\nline2\r\n')
|
||||
save(files[1], 'binary\r\n')
|
||||
const before = fingerprintInputs(directory, files, ['core'], false)
|
||||
save(files[0], 'line1\nline2\n')
|
||||
const after = fingerprintInputs(directory, files, ['core'], false)
|
||||
assert.equal(fingerprintOf(before), fingerprintOf(after))
|
||||
assert.notDeepEqual(before, after)
|
||||
save(files[1], 'binary\n')
|
||||
assert.notEqual(fingerprintOf(before), fingerprintOf(fingerprintInputs(directory, files, ['core'], false)))
|
||||
})
|
||||
|
||||
test('Release ledger requires exactly22 packages, all reviews and site-specific distribution', () => {
|
||||
const ledger = JSON.parse(fs.readFileSync(path.join(root, 'refactor/release-ledger.json')))
|
||||
const inventory = JSON.parse(fs.readFileSync(path.join(root, 'refactor/package-inventory.json'))).packages
|
||||
const tasks = new Map(sharedTasks.map(id => [id, {}]))
|
||||
validateLedger(ledger, inventory, tasks)
|
||||
assert.equal(ledger.packages.length, 22)
|
||||
assert.equal(ledger.packages.find(row => row.name === 'artplayer-vitepress').distribution, 'site')
|
||||
assert.deepEqual(ledger.siteGates, siteGates)
|
||||
for (const mutate of [data => data.packages.pop(), data => data.libraryGates.pop(), data => data.sharedTasks.pop(), data => data.packages.find(row => row.distribution === 'site').distribution = 'npm']) {
|
||||
const changed = structuredClone(ledger)
|
||||
mutate(changed)
|
||||
assert.throws(() => validateLedger(changed, inventory, tasks))
|
||||
}
|
||||
})
|
||||
|
||||
test('Release candidate verifies site output bytes and refuses using an npm artifact for site scope', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
execFileSync('git', ['init', '-q', directory])
|
||||
execFileSync('git', ['-c', 'user.name=Ledger test', '-c', 'user.email=ledger@example.invalid', 'commit', '--allow-empty', '-qm', 'fixture'], { cwd: directory })
|
||||
const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: directory, encoding: 'utf8' }).trim()
|
||||
save('output/index.html', '<html>verified build</html>')
|
||||
const manifest = JSON.stringify({ package: 'site', version: '2.0.0', outputRoot: 'output', files: [{ path: 'output/index.html', sha256: hash('<html>verified build</html>') }] })
|
||||
save('manifest.json', manifest)
|
||||
const row = { name: 'site', distribution: 'site', candidate: { kind: 'site-manifest', path: 'manifest.json', sourceCommit, version: '2.0.0', integrity: `sha512-${hash(manifest, 'sha512', 'base64')}` } }
|
||||
assert.deepEqual(checkedCandidate(directory, row).errors, [])
|
||||
save('output/index.html', 'stale')
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Site build changed/)
|
||||
save('output/extra.js', 'unlisted build output')
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Site output inventory differs/)
|
||||
row.candidate.kind = 'npm-tarball'
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Site requires/)
|
||||
})
|
||||
|
||||
test('Release candidate checks actual tarball manifest and detects digest/version/name mismatches', (t) => {
|
||||
const { directory, save } = temp(t)
|
||||
execFileSync('git', ['init', '-q', directory])
|
||||
execFileSync('git', ['-c', 'user.name=Ledger test', '-c', 'user.email=ledger@example.invalid', 'commit', '--allow-empty', '-qm', 'fixture'], { cwd: directory })
|
||||
const sourceCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: directory, encoding: 'utf8' }).trim()
|
||||
save('staging/package/package.json', JSON.stringify({ name: 'example', version: '2.0.0' }))
|
||||
execFileSync('tar', ['-czf', path.join(directory, 'candidate.tgz'), '-C', path.join(directory, 'staging'), 'package'])
|
||||
const integrity = `sha512-${hash(fs.readFileSync(path.join(directory, 'candidate.tgz')), 'sha512', 'base64')}`
|
||||
const row = { name: 'example', distribution: 'npm', candidate: { kind: 'npm-tarball', path: 'candidate.tgz', integrity, version: '2.0.0', sourceCommit } }
|
||||
assert.deepEqual(checkedCandidate(directory, row).errors, [])
|
||||
row.candidate.version = '2.1.0'
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Tarball version mismatch/)
|
||||
row.candidate.version = '2.0.0'
|
||||
row.name = 'wrong'
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Tarball package name mismatch/)
|
||||
row.name = 'example'
|
||||
save('candidate.tgz', 'tampered')
|
||||
assert.match(checkedCandidate(directory, row).errors[0], /Candidate integrity mismatch/)
|
||||
})
|
||||
Reference in new issue
Block a user