Files
av-tools/scripts/package-ffmpeg-source.mjs

363 lines
9.8 KiB
JavaScript

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 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 archiveName = `ffmpeg-core-${lock.coreVersion}-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 });
}