363 lines
9.8 KiB
JavaScript
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 });
|
|
}
|