Files
ArtPlayer/refactor/scripts/release-ledger.mjs

203 lines
12 KiB
JavaScript

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 { packedFiles } from '../../scripts/package-archive.mjs'
import { readImpactModel, repositoryPath } from './impact-model.mjs'
import { dependencyClosure, evaluatePackage, findingPackages, validateLedger } from './release-ledger-model.mjs'
import { 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')
packedFiles(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\/(?:scripts\/|fixtures\/|third-party\.json$)/.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
}