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[] } 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`) 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') 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 = 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 } } 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 } }