feat: publish av-tools 0.1.0
This commit is contained in:
362
scripts/package-ffmpeg-source.mjs
Normal file
362
scripts/package-ffmpeg-source.mjs
Normal file
@@ -0,0 +1,362 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user