import { createHash } from 'node:crypto'; import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile, } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import { createReadStream } from 'node:fs'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const lockPath = join(repositoryRoot, 'scripts', 'ffmpeg-source-lock.json'); const reviewedLockPath = join( repositoryRoot, 'scripts', 'reviewed-ffmpeg-core-lock.json' ); const readmePath = join(repositoryRoot, 'scripts', 'ffmpeg-source-README.md'); const releaseDirectory = join(repositoryRoot, 'release'); const force = process.argv.includes('--force'); for (const argument of process.argv.slice(2)) { if (argument !== '--force') { throw new Error(`Unknown argument: ${argument}`); } } function assertLock(lock) { if ( lock?.schemaVersion !== 1 || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test( lock.coreVersion ) || !/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(lock.wrapperRelease) || !Number.isSafeInteger(lock.sourceDateEpoch) || lock.sourceDateEpoch < 315532800 || lock.sourceDateEpoch > 4102444799 || !Array.isArray(lock.sources) || lock.sources.length === 0 ) { throw new TypeError('Invalid FFmpeg source lock header.'); } const directories = new Set(); for (const [index, source] of lock.sources.entries()) { if ( typeof source?.name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9 .()_+-]*$/u.test(source.name) || typeof source.directory !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/u.test(source.directory) || source.directory .split('/') .some((part) => part === '' || part === '.' || part === '..') || directories.has(source.directory) || typeof source.repository !== 'string' || !source.repository.startsWith('https://') || !/^[a-f0-9]{40}$/u.test(source.revision) ) { throw new TypeError(`Invalid FFmpeg source lock entry ${index}.`); } directories.add(source.directory); } } async function run(command, argumentsList, options = {}) { await new Promise((resolvePromise, rejectPromise) => { const child = spawn(command, argumentsList, { stdio: 'inherit', ...options, }); child.once('error', rejectPromise); child.once('exit', (code, signal) => { if (code === 0) { resolvePromise(); } else { rejectPromise( new Error( `${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}` ) ); } }); }); } async function output(command, argumentsList, options = {}) { return await new Promise((resolvePromise, rejectPromise) => { let stdout = ''; let stderr = ''; const child = spawn(command, argumentsList, { stdio: ['ignore', 'pipe', 'pipe'], ...options, }); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.once('error', rejectPromise); child.once('exit', (code, signal) => { if (code === 0) { resolvePromise(stdout.trim()); } else { rejectPromise( new Error( `${command} exited with ${code ?? `signal ${signal ?? 'unknown'}`}: ${stderr.trim()}` ) ); } }); }); } async function sha256(file) { const hash = createHash('sha256'); await new Promise((resolvePromise, rejectPromise) => { const stream = createReadStream(file); stream.on('data', (chunk) => hash.update(chunk)); stream.once('error', rejectPromise); stream.once('end', resolvePromise); }); return hash.digest('hex'); } async function optionalDetails(path) { try { return await lstat(path); } catch (error) { if (error && typeof error === 'object' && error.code === 'ENOENT') { return undefined; } throw error; } } async function ensureReleaseDirectory() { let details = await optionalDetails(releaseDirectory); if (!details) { await mkdir(releaseDirectory, { recursive: true }); details = await lstat(releaseDirectory); } if (details.isSymbolicLink() || !details.isDirectory()) { throw new Error( `Release path must be a real directory: ${releaseDirectory}` ); } } async function assertReplaceableOutput(path) { const details = await optionalDetails(path); if (!details) return false; if (!force) { throw new Error( `${path} already exists; pass --force to replace this exact artifact.` ); } if (details.isSymbolicLink() || !details.isFile()) { throw new Error(`Refusing to replace non-file output ${path}.`); } return true; } async function publishSourcePair({ archiveExists, archivePath, checksumExists, checksumPath, stagedArchivePath, stagedChecksumPath, stagingRoot, }) { const previousArchive = join(stagingRoot, 'previous-archive'); const previousChecksum = join(stagingRoot, 'previous-checksum'); let archiveBackedUp = false; let checksumBackedUp = false; let archivePublished = false; let checksumPublished = false; try { if (archiveExists) { await rename(archivePath, previousArchive); archiveBackedUp = true; } if (checksumExists) { await rename(checksumPath, previousChecksum); checksumBackedUp = true; } await rename(stagedArchivePath, archivePath); archivePublished = true; await rename(stagedChecksumPath, checksumPath); checksumPublished = true; } catch (error) { if (checksumPublished) await rm(checksumPath, { force: true }); if (archivePublished) await rm(archivePath, { force: true }); if (checksumBackedUp) await rename(previousChecksum, checksumPath); if (archiveBackedUp) await rename(previousArchive, archivePath); throw error; } } const lock = JSON.parse(await readFile(lockPath, 'utf8')); assertLock(lock); const reviewedLock = JSON.parse(await readFile(reviewedLockPath, 'utf8')); if ( reviewedLock?.schemaVersion !== 1 || reviewedLock.coreVersion !== lock.coreVersion || typeof reviewedLock.buildId !== 'string' || !/^[a-zA-Z0-9._-]+$/u.test(reviewedLock.buildId) || !reviewedLock.buildId.startsWith(`${lock.coreVersion}-`) ) { throw new Error( 'The reviewed-core lock does not identify a build-qualified source bundle.' ); } const archiveName = `ffmpeg-core-${reviewedLock.buildId}-corresponding-source.tar.xz`; const archivePath = join(releaseDirectory, archiveName); const checksumPath = `${archivePath}.sha256`; await ensureReleaseDirectory(); const archiveExists = await assertReplaceableOutput(archivePath); const checksumExists = await assertReplaceableOutput(checksumPath); const stagingRoot = await mkdtemp( join(releaseDirectory, '.ffmpeg-source-package-') ); const stagedArchivePath = join(stagingRoot, archiveName); const stagedChecksumPath = `${stagedArchivePath}.sha256`; let temporaryRoot; const archiveRootName = archiveName.replace(/\.tar\.xz$/u, ''); try { temporaryRoot = await mkdtemp( join(tmpdir(), 'av-tools-corresponding-source-') ); const archiveRoot = join(temporaryRoot, archiveRootName); const sourcesRoot = join(archiveRoot, 'sources'); await mkdir(sourcesRoot, { recursive: true }); const archiveReadmePath = join(archiveRoot, 'README.md'); const archiveLockPath = join(archiveRoot, 'SOURCE_LOCK.json'); await copyFile(readmePath, archiveReadmePath); await copyFile(lockPath, archiveLockPath); await chmod(archiveReadmePath, 0o644); await chmod(archiveLockPath, 0o644); for (const [index, source] of lock.sources.entries()) { const checkout = join(temporaryRoot, `checkout-${index}`); const sourceTar = join(temporaryRoot, `source-${index}.tar`); await mkdir(checkout); await run('git', ['init', '--quiet'], { cwd: checkout }); await run('git', ['remote', 'add', 'origin', source.repository], { cwd: checkout, }); await run( 'git', ['fetch', '--quiet', '--depth', '1', 'origin', source.revision], { cwd: checkout } ); const revision = await output('git', ['rev-parse', 'FETCH_HEAD'], { cwd: checkout, }); if (revision !== source.revision) { throw new Error( `${source.name} resolved to ${revision}, expected ${source.revision}.` ); } await run( 'git', [ 'archive', '--format=tar', `--prefix=sources/${source.directory}/`, `--output=${sourceTar}`, source.revision, ], { cwd: checkout } ); await run('tar', [ '--same-permissions', '-xf', sourceTar, '-C', archiveRoot, ]); process.stdout.write(`Archived ${source.name} at ${source.revision}\n`); } const timestamp = `@${lock.sourceDateEpoch}`; await run('find', [ archiveRoot, '-type', 'd', '-exec', 'chmod', '0755', '{}', '+', ]); await run('find', [ archiveRoot, '-type', 'f', '-perm', '/111', '-exec', 'chmod', '0755', '{}', '+', ]); await run('find', [ archiveRoot, '-type', 'f', '!', '-perm', '/111', '-exec', 'chmod', '0644', '{}', '+', ]); await run('find', [ archiveRoot, '-exec', 'touch', '-h', '-d', timestamp, '{}', '+', ]); await run('tar', [ '--sort=name', '--format=posix', '--owner=0', '--group=0', '--numeric-owner', `--mtime=${timestamp}`, '--pax-option=delete=atime,delete=ctime', '-C', temporaryRoot, '-cJf', stagedArchivePath, archiveRootName, ]); const digest = await sha256(stagedArchivePath); await writeFile(stagedChecksumPath, `${digest} ${basename(archivePath)}\n`, { mode: 0o644, }); await chmod(stagedArchivePath, 0o644); await chmod(stagedChecksumPath, 0o644); await publishSourcePair({ archiveExists, archivePath, checksumExists, checksumPath, stagedArchivePath, stagedChecksumPath, stagingRoot, }); process.stdout.write( `Corresponding Source: ${archivePath}\nSHA-256: ${digest}\n` ); } finally { if (temporaryRoot) { await rm(temporaryRoot, { recursive: true, force: true }); } await rm(stagingRoot, { recursive: true, force: true }); }