feat: publish av-tools 0.1.0
This commit is contained in:
150
scripts/checksum-release.mjs
Normal file
150
scripts/checksum-release.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access, lstat, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
|
||||
export function sha256(data) {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
export async function sha256File(file) {
|
||||
return sha256(await readFile(file));
|
||||
}
|
||||
|
||||
async function requireRegularFile(file, label) {
|
||||
const details = await lstat(file);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`${label} must be a regular file: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function checksumLine(digest, archiveName) {
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) {
|
||||
throw new Error('SHA-256 digest must be 64 lowercase hexadecimal digits.');
|
||||
}
|
||||
if (
|
||||
typeof archiveName !== 'string' ||
|
||||
archiveName.length === 0 ||
|
||||
archiveName.includes('/') ||
|
||||
archiveName.includes('\\') ||
|
||||
archiveName.includes('\n') ||
|
||||
archiveName.includes('\r')
|
||||
) {
|
||||
throw new Error('Checksum archive name must be one safe basename.');
|
||||
}
|
||||
return `${digest} ${archiveName}\n`;
|
||||
}
|
||||
|
||||
export function parseChecksumLine(contents) {
|
||||
const match = /^([a-f0-9]{64}) ([^/\\\r\n]+)\n?$/.exec(contents);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
'Checksum sidecar must contain `<lowercase-sha256> <archive-basename>`.'
|
||||
);
|
||||
}
|
||||
return { digest: match[1], archiveName: match[2] };
|
||||
}
|
||||
|
||||
export async function verifyChecksum(archivePath, sidecarPath) {
|
||||
await requireRegularFile(archivePath, 'Release archive');
|
||||
await requireRegularFile(sidecarPath, 'Checksum sidecar');
|
||||
const expected = parseChecksumLine(await readFile(sidecarPath, 'utf8'));
|
||||
if (expected.archiveName !== path.basename(archivePath)) {
|
||||
throw new Error(
|
||||
`Checksum names ${expected.archiveName}, not ${path.basename(archivePath)}.`
|
||||
);
|
||||
}
|
||||
const actual = await sha256File(archivePath);
|
||||
if (actual !== expected.digest) {
|
||||
throw new Error(
|
||||
`Checksum mismatch for ${path.basename(archivePath)}: expected ${expected.digest}, received ${actual}.`
|
||||
);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
export async function writeChecksum(archivePath, options = {}) {
|
||||
const sidecarPath = options.sidecarPath ?? `${archivePath}.sha256`;
|
||||
await requireRegularFile(archivePath, 'Release archive');
|
||||
if (options.force && (await exists(sidecarPath))) {
|
||||
await requireRegularFile(sidecarPath, 'Existing checksum sidecar');
|
||||
}
|
||||
const digest = await sha256File(archivePath);
|
||||
await writeFile(
|
||||
sidecarPath,
|
||||
checksumLine(digest, path.basename(archivePath)),
|
||||
{
|
||||
encoding: 'utf8',
|
||||
flag: options.force ? 'w' : 'wx',
|
||||
}
|
||||
);
|
||||
return { digest, sidecarPath };
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await access(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')
|
||||
);
|
||||
const defaultArchive = path.join(
|
||||
repositoryRoot,
|
||||
'release',
|
||||
`av-tools-${packageJson.version}.zip`
|
||||
);
|
||||
const argumentsList = process.argv.slice(2);
|
||||
const write = argumentsList.includes('--write');
|
||||
const force = argumentsList.includes('--force');
|
||||
const positional = argumentsList.filter(
|
||||
(argument) => argument !== '--write' && argument !== '--force'
|
||||
);
|
||||
if (positional.length > 1) {
|
||||
throw new Error(
|
||||
'Usage: node scripts/checksum-release.mjs [archive.zip] [--write] [--force]'
|
||||
);
|
||||
}
|
||||
|
||||
const archivePath = path.resolve(
|
||||
repositoryRoot,
|
||||
positional[0] ?? defaultArchive
|
||||
);
|
||||
const sidecarPath = `${archivePath}.sha256`;
|
||||
if (write) {
|
||||
const result = await writeChecksum(archivePath, { force, sidecarPath });
|
||||
console.log(
|
||||
`Wrote ${path.relative(repositoryRoot, result.sidecarPath)} (${result.digest})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!(await exists(sidecarPath))) {
|
||||
throw new Error(
|
||||
`${path.relative(repositoryRoot, sidecarPath)} is missing; pass --write to create it.`
|
||||
);
|
||||
}
|
||||
const digest = await verifyChecksum(archivePath, sidecarPath);
|
||||
console.log(
|
||||
`Verified ${path.relative(repositoryRoot, archivePath)} (${digest})`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
||||
) {
|
||||
await main();
|
||||
}
|
||||
138
scripts/copy-ffmpeg-assets.mjs
Normal file
138
scripts/copy-ffmpeg-assets.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const expectedVersion = '0.12.10';
|
||||
const outputRoot = join(
|
||||
repositoryRoot,
|
||||
'public',
|
||||
'vendor',
|
||||
'ffmpeg',
|
||||
expectedVersion
|
||||
);
|
||||
|
||||
const packages = [
|
||||
{
|
||||
packageName: '@ffmpeg/core',
|
||||
mode: 'st',
|
||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm'],
|
||||
},
|
||||
{
|
||||
packageName: '@ffmpeg/core-mt',
|
||||
mode: 'mt',
|
||||
files: ['ffmpeg-core.js', 'ffmpeg-core.wasm', 'ffmpeg-core.worker.js'],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedHashes = new Map([
|
||||
[
|
||||
'@ffmpeg/core:ffmpeg-core.js',
|
||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core:ffmpeg-core.wasm',
|
||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.js',
|
||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.wasm',
|
||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
||||
],
|
||||
[
|
||||
'@ffmpeg/core-mt:ffmpeg-core.worker.js',
|
||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
||||
],
|
||||
]);
|
||||
|
||||
async function loadPackageVersion(packageName) {
|
||||
const packagePath = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'package.json'
|
||||
);
|
||||
const parsed = JSON.parse(await readFile(packagePath, 'utf8'));
|
||||
if (parsed.version !== expectedVersion) {
|
||||
throw new Error(
|
||||
`${packageName} ${parsed.version ?? '<unknown>'} is installed; expected exactly ${expectedVersion}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAsset(packageName, mode, fileName) {
|
||||
const source = join(
|
||||
repositoryRoot,
|
||||
'node_modules',
|
||||
...packageName.split('/'),
|
||||
'dist',
|
||||
'esm',
|
||||
fileName
|
||||
);
|
||||
const destination = join(outputRoot, mode, fileName);
|
||||
const sourceBytes = await readFile(source).catch((error) => {
|
||||
throw new Error(`Required FFmpeg asset is missing: ${source}`, {
|
||||
cause: error,
|
||||
});
|
||||
});
|
||||
const sourceDigest = createHash('sha256').update(sourceBytes).digest('hex');
|
||||
const expectedDigest = expectedHashes.get(`${packageName}:${fileName}`);
|
||||
if (!expectedDigest || sourceDigest !== expectedDigest) {
|
||||
throw new Error(
|
||||
`Pinned FFmpeg asset checksum mismatch for ${packageName}/${fileName}`
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
dereference: true,
|
||||
errorOnExist: false,
|
||||
force: true,
|
||||
preserveTimestamps: false,
|
||||
});
|
||||
|
||||
const destinationBytes = await readFile(destination);
|
||||
if (!sourceBytes.equals(destinationBytes)) {
|
||||
throw new Error(`Copied FFmpeg asset differs from its source: ${fileName}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: relative(outputRoot, destination).replaceAll('\\', '/'),
|
||||
bytes: destinationBytes.byteLength,
|
||||
sha256: sourceDigest,
|
||||
};
|
||||
}
|
||||
|
||||
await rm(outputRoot, { force: true, recursive: true });
|
||||
|
||||
const assets = [];
|
||||
for (const entry of packages) {
|
||||
await loadPackageVersion(entry.packageName);
|
||||
for (const fileName of entry.files) {
|
||||
assets.push(await copyAsset(entry.packageName, entry.mode, fileName));
|
||||
}
|
||||
}
|
||||
|
||||
assets.sort((left, right) => left.path.localeCompare(right.path, 'en'));
|
||||
const versionManifest = {
|
||||
schemaVersion: 1,
|
||||
coreVersion: expectedVersion,
|
||||
packages: {
|
||||
'@ffmpeg/core': expectedVersion,
|
||||
'@ffmpeg/core-mt': expectedVersion,
|
||||
},
|
||||
assets,
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
join(outputRoot, 'version.json'),
|
||||
`${JSON.stringify(versionManifest, null, 2)}\n`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Copied ${assets.length} pinned FFmpeg assets to ${relative(repositoryRoot, outputRoot)}`
|
||||
);
|
||||
50
scripts/ffmpeg-source-README.md
Normal file
50
scripts/ffmpeg-source-README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# FFmpeg WebAssembly Corresponding Source
|
||||
|
||||
This archive accompanies the exact `@ffmpeg/core` and `@ffmpeg/core-mt`
|
||||
0.12.10 object code distributed by av-tools 0.1.0.
|
||||
|
||||
`SOURCE_LOCK.json` records the immutable Git commit for ffmpeg.wasm, FFmpeg and
|
||||
every external library selected by the upstream v12.15 build. The source trees
|
||||
are stored under `sources/`. In particular, the upstream branch names
|
||||
`4-cores` and `master` have been resolved to the commits from which the
|
||||
published core was built; neither branch changed between its recorded commit
|
||||
and the upstream core release.
|
||||
|
||||
The original build-control files are in `sources/ffmpeg.wasm/`, including:
|
||||
|
||||
- `Dockerfile`;
|
||||
- `Makefile`;
|
||||
- `build/ffmpeg.sh`;
|
||||
- `build/ffmpeg-wasm.sh`;
|
||||
- the other dependency scripts under `build/`.
|
||||
|
||||
The upstream build selects Emscripten SDK 3.1.40 and FFmpeg n5.1.4. av-tools
|
||||
does not modify the five published JavaScript/WebAssembly core assets. Their
|
||||
exact sizes, hashes and embedded configure arguments are recorded in the
|
||||
application's `SOURCE.md` and `docs/FFMPEG_BUILD.md`.
|
||||
|
||||
The source archive preserves each component's own licence and copyright
|
||||
notices. av-tools itself is distributed separately under
|
||||
GPL-3.0-or-later. Codec patent permissions, where required, are separate from
|
||||
copyright licences.
|
||||
|
||||
To fetch and verify an identity:
|
||||
|
||||
```sh
|
||||
git init source-check
|
||||
git -C source-check fetch --depth 1 <repository> <revision>
|
||||
test "$(git -C source-check rev-parse FETCH_HEAD)" = "<revision>"
|
||||
```
|
||||
|
||||
The included ffmpeg.wasm Dockerfile documents the historical upstream build,
|
||||
but still names remote tags and branches. It is not a turnkey offline or
|
||||
byte-for-byte reproducible build recipe. To reconstruct a pinned build,
|
||||
adapt each remote `ADD` to use the corresponding bundled source tree. Replace
|
||||
the zimg `git clone -b ... --recursive` step with the bundled `sources/zimg/`
|
||||
tree, which already contains its pinned GoogleTest submodule at
|
||||
`test/extra/googletest/`. Keep the included build scripts and Emscripten
|
||||
version unchanged. The upstream targets are `make prd` for single-thread and
|
||||
`make prd-mt` for multithread.
|
||||
|
||||
General-purpose build tooling such as Git, Docker/BuildKit and the Emscripten
|
||||
3.1.40 container image is not copied into this archive.
|
||||
116
scripts/ffmpeg-source-lock.json
Normal file
116
scripts/ffmpeg-source-lock.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"coreVersion": "0.12.10",
|
||||
"wrapperRelease": "v12.15",
|
||||
"sourceDateEpoch": 1736208000,
|
||||
"sources": [
|
||||
{
|
||||
"name": "ffmpeg.wasm",
|
||||
"directory": "ffmpeg.wasm",
|
||||
"repository": "https://github.com/ffmpegwasm/ffmpeg.wasm.git",
|
||||
"revision": "71aa99d37c02a7b4c435275ca9ef50e612f6efa1"
|
||||
},
|
||||
{
|
||||
"name": "FFmpeg",
|
||||
"directory": "FFmpeg",
|
||||
"repository": "https://github.com/FFmpeg/FFmpeg.git",
|
||||
"revision": "4729204c17f756e186d622060088371d10b34f7e"
|
||||
},
|
||||
{
|
||||
"name": "x264",
|
||||
"directory": "x264",
|
||||
"repository": "https://github.com/ffmpegwasm/x264.git",
|
||||
"revision": "33cac6b77d5b9259c552156013a817ab23119612"
|
||||
},
|
||||
{
|
||||
"name": "x265",
|
||||
"directory": "x265",
|
||||
"repository": "https://github.com/ffmpegwasm/x265.git",
|
||||
"revision": "2bb5520e9596f361bf0ed81b3b8da0d7fd999069"
|
||||
},
|
||||
{
|
||||
"name": "libvpx",
|
||||
"directory": "libvpx",
|
||||
"repository": "https://github.com/ffmpegwasm/libvpx.git",
|
||||
"revision": "10b9492dcf05b652e2e4b370e205bd605d421972"
|
||||
},
|
||||
{
|
||||
"name": "LAME",
|
||||
"directory": "lame",
|
||||
"repository": "https://github.com/ffmpegwasm/lame.git",
|
||||
"revision": "2badea1974ae36cb8312afe99cff1e6b3b5decee"
|
||||
},
|
||||
{
|
||||
"name": "Ogg",
|
||||
"directory": "ogg",
|
||||
"repository": "https://github.com/ffmpegwasm/Ogg.git",
|
||||
"revision": "bada45718453ac27b56773ae663f7e65112f6a6e"
|
||||
},
|
||||
{
|
||||
"name": "Theora",
|
||||
"directory": "theora",
|
||||
"repository": "https://github.com/ffmpegwasm/theora.git",
|
||||
"revision": "7ffd8b2ecfc2d93ae5e16028e7528e609266bfbf"
|
||||
},
|
||||
{
|
||||
"name": "Opus",
|
||||
"directory": "opus",
|
||||
"repository": "https://github.com/ffmpegwasm/opus.git",
|
||||
"revision": "e85ed7726db5d677c9c0677298ea0cb9c65bdd23"
|
||||
},
|
||||
{
|
||||
"name": "Vorbis",
|
||||
"directory": "vorbis",
|
||||
"repository": "https://github.com/ffmpegwasm/vorbis.git",
|
||||
"revision": "7798164043197d7e33f02de4353ce2aa5b248225"
|
||||
},
|
||||
{
|
||||
"name": "zlib",
|
||||
"directory": "zlib",
|
||||
"repository": "https://github.com/ffmpegwasm/zlib.git",
|
||||
"revision": "cacf7f1d4e3d44d871b605da3b647f07d718623f"
|
||||
},
|
||||
{
|
||||
"name": "libwebp",
|
||||
"directory": "libwebp",
|
||||
"repository": "https://github.com/ffmpegwasm/libwebp.git",
|
||||
"revision": "ca332209cb5567c9b249c86788cb2dbf8847e760"
|
||||
},
|
||||
{
|
||||
"name": "FreeType",
|
||||
"directory": "freetype2",
|
||||
"repository": "https://github.com/ffmpegwasm/freetype2.git",
|
||||
"revision": "6a2b3e4007e794bfc6c91030d0ed987f925164a8"
|
||||
},
|
||||
{
|
||||
"name": "FriBidi",
|
||||
"directory": "fribidi",
|
||||
"repository": "https://github.com/fribidi/fribidi.git",
|
||||
"revision": "f9e8e71a6fbf4a4619481284c9f484d10e559995"
|
||||
},
|
||||
{
|
||||
"name": "HarfBuzz",
|
||||
"directory": "harfbuzz",
|
||||
"repository": "https://github.com/harfbuzz/harfbuzz.git",
|
||||
"revision": "4a1d891c6317d2c83e5f3c2607ec5f5ccedffcde"
|
||||
},
|
||||
{
|
||||
"name": "libass",
|
||||
"directory": "libass",
|
||||
"repository": "https://github.com/libass/libass.git",
|
||||
"revision": "d149636f502f5774ae1a8fb4c554b122674393b2"
|
||||
},
|
||||
{
|
||||
"name": "zimg",
|
||||
"directory": "zimg",
|
||||
"repository": "https://github.com/sekrit-twc/zimg.git",
|
||||
"revision": "e5b0de6bebbcbc66732ed5afaafef6b2c7dfef87"
|
||||
},
|
||||
{
|
||||
"name": "GoogleTest (zimg build submodule)",
|
||||
"directory": "zimg/test/extra/googletest",
|
||||
"repository": "https://github.com/google/googletest.git",
|
||||
"revision": "703bd9caab50b139428cea1aaff9974ebee5742e"
|
||||
}
|
||||
]
|
||||
}
|
||||
315
scripts/generate-fixtures.sh
Normal file
315
scripts/generate-fixtures.sh
Normal file
@@ -0,0 +1,315 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
export LC_ALL=C
|
||||
export TZ=UTC
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
|
||||
project_root=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
|
||||
output_dir="$project_root/tests/fixtures/generated"
|
||||
force=false
|
||||
|
||||
if [ "${1:-}" = "--force" ]; then
|
||||
force=true
|
||||
shift
|
||||
fi
|
||||
|
||||
if [ "$#" -ne 0 ]; then
|
||||
echo "Usage: scripts/generate-fixtures.sh [--force]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for command_name in ffmpeg ffprobe head sha256sum mktemp; do
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
echo "Required development command is unavailable: $command_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
fixture_names="
|
||||
pattern-av.mp4
|
||||
compatible-a.mp4
|
||||
compatible-b.mp4
|
||||
different-dimensions.mp4
|
||||
tone.wav
|
||||
tone.mp3
|
||||
tone.flac
|
||||
pattern.webm
|
||||
subtitle.srt
|
||||
subtitle.vtt
|
||||
subtitle.ass
|
||||
metadata.mp4
|
||||
chapters.mp4
|
||||
attached-cover.mp3
|
||||
truncated.mp4
|
||||
SHA256SUMS
|
||||
"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
if [ -L "$output_dir" ] || [ ! -d "$output_dir" ]; then
|
||||
echo "Fixture output must be a real directory: $output_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
canonical_output_dir=$(CDPATH= cd -- "$output_dir" && pwd -P)
|
||||
if [ "$canonical_output_dir" != "$project_root/tests/fixtures/generated" ]; then
|
||||
echo "Fixture output resolves outside the expected project path." >&2
|
||||
exit 1
|
||||
fi
|
||||
for fixture_name in $fixture_names; do
|
||||
fixture_path="$output_dir/$fixture_name"
|
||||
if [ -e "$fixture_path" ] && [ "$force" != true ]; then
|
||||
echo "Fixture already exists: $fixture_path" >&2
|
||||
echo "Pass --force to replace only the known generated fixture set." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$force" = true ]; then
|
||||
for fixture_name in $fixture_names; do
|
||||
rm -f -- "$output_dir/$fixture_name"
|
||||
done
|
||||
fi
|
||||
|
||||
temporary_dir=$(mktemp -d "/tmp/av-tools-fixtures.XXXXXX")
|
||||
case "$temporary_dir" in
|
||||
/tmp/av-tools-fixtures.*) ;;
|
||||
*)
|
||||
echo "Refusing unexpected temporary path: $temporary_dir" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
trap 'rm -rf -- "$temporary_dir"' EXIT HUP INT TERM
|
||||
|
||||
run_ffmpeg() {
|
||||
ffmpeg \
|
||||
-hide_banner \
|
||||
-loglevel error \
|
||||
-y \
|
||||
-fflags +bitexact \
|
||||
-filter_threads 1 \
|
||||
-filter_complex_threads 1 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "testsrc2=size=160x90:rate=12" \
|
||||
-f lavfi -i "sine=frequency=440:sample_rate=16000" \
|
||||
-t 2 -shortest \
|
||||
-map 0:v:0 -map 1:a:0 \
|
||||
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 12 -threads 1 \
|
||||
-c:a aac -b:a 32k -ar 16000 -ac 1 \
|
||||
-flags:v +bitexact -flags:a +bitexact \
|
||||
-metadata title="Generated AV fixture" \
|
||||
-metadata creation_time="2020-01-01T00:00:00Z" \
|
||||
-movflags +faststart \
|
||||
"$output_dir/pattern-av.mp4"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "color=c=red:size=128x72:rate=10" \
|
||||
-f lavfi -i "sine=frequency=330:sample_rate=16000" \
|
||||
-t 1 -shortest \
|
||||
-map 0:v:0 -map 1:a:0 \
|
||||
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
|
||||
-c:a aac -b:a 32k -ar 16000 -ac 1 \
|
||||
-flags:v +bitexact -flags:a +bitexact \
|
||||
-metadata creation_time="2020-01-01T00:00:00Z" \
|
||||
-movflags +faststart \
|
||||
"$output_dir/compatible-a.mp4"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "color=c=blue:size=128x72:rate=10" \
|
||||
-f lavfi -i "sine=frequency=550:sample_rate=16000" \
|
||||
-t 1 -shortest \
|
||||
-map 0:v:0 -map 1:a:0 \
|
||||
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
|
||||
-c:a aac -b:a 32k -ar 16000 -ac 1 \
|
||||
-flags:v +bitexact -flags:a +bitexact \
|
||||
-metadata creation_time="2020-01-01T00:00:00Z" \
|
||||
-movflags +faststart \
|
||||
"$output_dir/compatible-b.mp4"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "testsrc2=size=96x96:rate=10" \
|
||||
-f lavfi -i "sine=frequency=660:sample_rate=16000" \
|
||||
-t 1 -shortest \
|
||||
-map 0:v:0 -map 1:a:0 \
|
||||
-c:v libx264 -preset ultrafast -crf 35 -pix_fmt yuv420p -g 10 -threads 1 \
|
||||
-c:a aac -b:a 32k -ar 16000 -ac 1 \
|
||||
-flags:v +bitexact -flags:a +bitexact \
|
||||
-metadata creation_time="2020-01-01T00:00:00Z" \
|
||||
-movflags +faststart \
|
||||
"$output_dir/different-dimensions.mp4"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
|
||||
-c:a pcm_s16le -flags:a +bitexact \
|
||||
-metadata title="Generated tone" \
|
||||
"$output_dir/tone.wav"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
|
||||
-c:a libmp3lame -b:a 32k -ar 16000 -ac 1 -flags:a +bitexact \
|
||||
-id3v2_version 3 -write_xing 0 \
|
||||
-metadata title="Generated tone" \
|
||||
"$output_dir/tone.mp3"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
|
||||
-c:a flac -compression_level 5 -flags:a +bitexact \
|
||||
-metadata title="Generated tone" \
|
||||
"$output_dir/tone.flac"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "testsrc2=size=128x72:rate=10" \
|
||||
-f lavfi -i "sine=frequency=440:sample_rate=16000" \
|
||||
-t 1 -shortest \
|
||||
-map 0:v:0 -map 1:a:0 \
|
||||
-c:v libvpx -deadline good -cpu-used 8 -crf 50 -b:v 80k -threads 1 \
|
||||
-c:a libvorbis -b:a 32k -ar 16000 -ac 1 \
|
||||
-flags:v +bitexact -flags:a +bitexact \
|
||||
-metadata title="Generated WebM fixture" \
|
||||
-fflags +bitexact \
|
||||
"$output_dir/pattern.webm"
|
||||
|
||||
printf '%s\n' \
|
||||
'1' \
|
||||
'00:00:00,100 --> 00:00:00,700' \
|
||||
'Generated subtitle' \
|
||||
>"$output_dir/subtitle.srt"
|
||||
|
||||
printf '%s\n' \
|
||||
'WEBVTT' \
|
||||
'' \
|
||||
'00:00:00.100 --> 00:00:00.700' \
|
||||
'Generated subtitle' \
|
||||
>"$output_dir/subtitle.vtt"
|
||||
|
||||
printf '%s\n' \
|
||||
'[Script Info]' \
|
||||
'ScriptType: v4.00+' \
|
||||
'PlayResX: 128' \
|
||||
'PlayResY: 72' \
|
||||
'' \
|
||||
'[V4+ Styles]' \
|
||||
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding' \
|
||||
'Style: Default,sans-serif,12,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,8,8,8,1' \
|
||||
'' \
|
||||
'[Events]' \
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text' \
|
||||
'Dialogue: 0,0:00:00.10,0:00:00.70,Default,,0,0,0,,Generated subtitle' \
|
||||
>"$output_dir/subtitle.ass"
|
||||
|
||||
run_ffmpeg \
|
||||
-i "$output_dir/compatible-a.mp4" \
|
||||
-map 0 -c copy \
|
||||
-metadata title="Metadata fixture" \
|
||||
-metadata artist="add ideas test generator" \
|
||||
-metadata comment="Local generated media" \
|
||||
-metadata creation_time="2020-01-01T00:00:00Z" \
|
||||
"$output_dir/metadata.mp4"
|
||||
|
||||
printf '%s\n' \
|
||||
';FFMETADATA1' \
|
||||
'title=Chapter fixture' \
|
||||
'[CHAPTER]' \
|
||||
'TIMEBASE=1/1000' \
|
||||
'START=0' \
|
||||
'END=500' \
|
||||
'title=First' \
|
||||
'[CHAPTER]' \
|
||||
'TIMEBASE=1/1000' \
|
||||
'START=500' \
|
||||
'END=1000' \
|
||||
'title=Second' \
|
||||
>"$temporary_dir/chapters.ffmeta"
|
||||
|
||||
run_ffmpeg \
|
||||
-i "$output_dir/compatible-b.mp4" \
|
||||
-i "$temporary_dir/chapters.ffmeta" \
|
||||
-map 0 -map_metadata 1 -map_chapters 1 -c copy \
|
||||
-metadata creation_time="2020-01-01T00:00:00Z" \
|
||||
"$output_dir/chapters.mp4"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "color=c=yellow:size=32x32:rate=1" \
|
||||
-frames:v 1 -c:v png -threads 1 -flags:v +bitexact \
|
||||
"$temporary_dir/cover.png"
|
||||
|
||||
run_ffmpeg \
|
||||
-f lavfi -i "sine=frequency=440:sample_rate=16000:duration=1" \
|
||||
-i "$temporary_dir/cover.png" \
|
||||
-map 0:a:0 -map 1:v:0 \
|
||||
-c:a libmp3lame -b:a 32k -ar 16000 -ac 1 \
|
||||
-c:v png -threads 1 -disposition:v:0 attached_pic \
|
||||
-id3v2_version 3 -write_xing 0 \
|
||||
-metadata title="Attached cover fixture" \
|
||||
-metadata:s:v:0 title="Generated cover" \
|
||||
-metadata:s:v:0 comment="Cover (front)" \
|
||||
"$output_dir/attached-cover.mp3"
|
||||
|
||||
head -c 128 "$output_dir/compatible-a.mp4" >"$output_dir/truncated.mp4"
|
||||
|
||||
for media_name in \
|
||||
attached-cover.mp3 \
|
||||
chapters.mp4 \
|
||||
compatible-a.mp4 \
|
||||
compatible-b.mp4 \
|
||||
different-dimensions.mp4 \
|
||||
metadata.mp4 \
|
||||
pattern-av.mp4 \
|
||||
pattern.webm \
|
||||
tone.flac \
|
||||
tone.mp3 \
|
||||
tone.wav
|
||||
do
|
||||
ffprobe \
|
||||
-v error \
|
||||
-show_entries format=format_name,duration:stream=index,codec_name,codec_type \
|
||||
-of json \
|
||||
"$output_dir/$media_name" \
|
||||
>/dev/null
|
||||
done
|
||||
|
||||
truncated_streams=$(
|
||||
ffprobe \
|
||||
-v error \
|
||||
-show_entries stream=index \
|
||||
-of csv=p=0 \
|
||||
"$output_dir/truncated.mp4"
|
||||
)
|
||||
if [ -n "$truncated_streams" ]; then
|
||||
echo "Truncated fixture unexpectedly contains a usable stream." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$output_dir"
|
||||
sha256sum \
|
||||
attached-cover.mp3 \
|
||||
chapters.mp4 \
|
||||
compatible-a.mp4 \
|
||||
compatible-b.mp4 \
|
||||
different-dimensions.mp4 \
|
||||
metadata.mp4 \
|
||||
pattern-av.mp4 \
|
||||
pattern.webm \
|
||||
subtitle.ass \
|
||||
subtitle.srt \
|
||||
subtitle.vtt \
|
||||
tone.flac \
|
||||
tone.mp3 \
|
||||
tone.wav \
|
||||
truncated.mp4 \
|
||||
>SHA256SUMS
|
||||
)
|
||||
|
||||
for fixture_name in $fixture_names; do
|
||||
chmod 0644 "$output_dir/$fixture_name"
|
||||
done
|
||||
|
||||
echo "Generated fixtures in $output_dir"
|
||||
ffmpeg -version | sed -n '1p'
|
||||
(
|
||||
cd "$output_dir"
|
||||
sha256sum -c SHA256SUMS
|
||||
)
|
||||
46
scripts/generate-toolbox-manifest.mjs
Normal file
46
scripts/generate-toolbox-manifest.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sourcePath = join(
|
||||
repositoryRoot,
|
||||
'src',
|
||||
'toolbox',
|
||||
'manifest.source.json'
|
||||
);
|
||||
const outputPath = join(repositoryRoot, 'public', 'toolbox-app.json');
|
||||
const packagePath = join(repositoryRoot, 'package.json');
|
||||
const checkOnly = process.argv.includes('--check');
|
||||
|
||||
const source = JSON.parse(await readFile(sourcePath, 'utf8'));
|
||||
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'));
|
||||
|
||||
if (source.version !== packageJson.version) {
|
||||
throw new Error(
|
||||
`Manifest version ${source.version} differs from package version ${packageJson.version}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
source.source?.repository !== 'https://git.add-ideas.de/zemion/av-tools' ||
|
||||
source.source?.license !== 'GPL-3.0-or-later'
|
||||
) {
|
||||
throw new Error(
|
||||
'Manifest source must identify the canonical av-tools repository and GPL-3.0-or-later licence'
|
||||
);
|
||||
}
|
||||
|
||||
const serialized = `${JSON.stringify(source, null, 2)}\n`;
|
||||
if (checkOnly) {
|
||||
const current = await readFile(outputPath, 'utf8').catch(() => '');
|
||||
if (current !== serialized) {
|
||||
throw new Error(
|
||||
`${relative(repositoryRoot, outputPath)} is stale; run npm run manifest:generate`
|
||||
);
|
||||
}
|
||||
console.log('Toolbox manifest is synchronized with its canonical source');
|
||||
} else {
|
||||
await writeFile(outputPath, serialized);
|
||||
console.log(`Generated ${relative(repositoryRoot, outputPath)}`);
|
||||
}
|
||||
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 });
|
||||
}
|
||||
782
scripts/package-release.mjs
Normal file
782
scripts/package-release.mjs
Normal file
@@ -0,0 +1,782 @@
|
||||
import {
|
||||
access,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
realpath,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { zipSync } from 'fflate';
|
||||
import { checksumLine, sha256 } from './checksum-release.mjs';
|
||||
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
|
||||
const maximumEntryBytes = 256 * 1024 * 1024;
|
||||
const maximumArchiveBytes = 2 * 1024 * 1024 * 1024;
|
||||
const applicationLicenseSpdx = 'GPL-3.0-or-later';
|
||||
const canonicalRepository = 'https://git.add-ideas.de/zemion/av-tools';
|
||||
const canonicalPackageRepository =
|
||||
'git+https://git.add-ideas.de/zemion/av-tools.git';
|
||||
const requiredNativeLegalFiles = [
|
||||
'LICENSES/native/FFmpeg-LICENSE.md',
|
||||
'LICENSES/native/FreeType-LICENSE.txt',
|
||||
'LICENSES/native/FriBidi-COPYING.txt',
|
||||
'LICENSES/native/HarfBuzz-COPYING.txt',
|
||||
'LICENSES/native/LAME-COPYING.txt',
|
||||
'LICENSES/native/LAME-LICENSE.txt',
|
||||
'LICENSES/native/Ogg-COPYING.txt',
|
||||
'LICENSES/native/Opus-COPYING.txt',
|
||||
'LICENSES/native/Theora-COPYING.txt',
|
||||
'LICENSES/native/Vorbis-COPYING.txt',
|
||||
'LICENSES/native/libass-COPYING.txt',
|
||||
'LICENSES/native/libvpx-LICENSE.txt',
|
||||
'LICENSES/native/libvpx-PATENTS.txt',
|
||||
'LICENSES/native/libwebp-COPYING.txt',
|
||||
'LICENSES/native/libwebp-PATENTS.txt',
|
||||
'LICENSES/native/x264-COPYING.txt',
|
||||
'LICENSES/native/x265-COPYING.txt',
|
||||
'LICENSES/native/zimg-COPYING.txt',
|
||||
'LICENSES/native/zlib-README.txt',
|
||||
];
|
||||
|
||||
const ffmpegLicenseCoverage = new Map([
|
||||
['@ffmpeg/ffmpeg', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/util', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/types', 'LICENSES/ffmpeg.wasm-MIT.txt'],
|
||||
['@ffmpeg/core', 'LICENSES/GPL-2.0-or-later.txt'],
|
||||
['@ffmpeg/core-mt', 'LICENSES/GPL-2.0-or-later.txt'],
|
||||
]);
|
||||
|
||||
function compareText(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
export function safeArchivePath(fileName) {
|
||||
if (
|
||||
typeof fileName !== 'string' ||
|
||||
fileName.length === 0 ||
|
||||
fileName.includes('\0') ||
|
||||
fileName.includes('\\')
|
||||
) {
|
||||
throw new Error(`Invalid release path: ${String(fileName)}`);
|
||||
}
|
||||
if (fileName.startsWith('/') || /^[A-Za-z]:\//.test(fileName)) {
|
||||
throw new Error(`Absolute release path is forbidden: ${fileName}`);
|
||||
}
|
||||
const components = fileName.split('/');
|
||||
if (
|
||||
components.some(
|
||||
(component) => component === '' || component === '.' || component === '..'
|
||||
)
|
||||
) {
|
||||
throw new Error(`Ambiguous or traversing release path: ${fileName}`);
|
||||
}
|
||||
const normalized = path.posix.normalize(fileName);
|
||||
if (normalized !== fileName) {
|
||||
throw new Error(`Non-canonical release path: ${fileName}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function relativeArchivePath(root, file) {
|
||||
return safeArchivePath(
|
||||
path.relative(root, file).split(path.sep).join(path.posix.sep)
|
||||
);
|
||||
}
|
||||
|
||||
function isInside(root, candidate) {
|
||||
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
||||
}
|
||||
|
||||
async function collectDirectoryRecursive(
|
||||
canonicalRoot,
|
||||
sourceRoot,
|
||||
directory,
|
||||
prefix,
|
||||
entries
|
||||
) {
|
||||
const canonicalDirectory = await realpath(directory);
|
||||
if (!isInside(canonicalRoot, canonicalDirectory)) {
|
||||
throw new Error(`Directory resolves outside packaging root: ${directory}`);
|
||||
}
|
||||
|
||||
const children = await readdir(directory, { withFileTypes: true });
|
||||
children.sort((left, right) => compareText(left.name, right.name));
|
||||
for (const child of children) {
|
||||
const childPath = path.join(directory, child.name);
|
||||
const details = await lstat(childPath);
|
||||
if (details.isSymbolicLink()) {
|
||||
throw new Error(`Refusing release symlink: ${childPath}`);
|
||||
}
|
||||
if (details.isDirectory()) {
|
||||
await collectDirectoryRecursive(
|
||||
canonicalRoot,
|
||||
sourceRoot,
|
||||
childPath,
|
||||
prefix,
|
||||
entries
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!details.isFile()) {
|
||||
throw new Error(`Refusing special release entry: ${childPath}`);
|
||||
}
|
||||
const canonicalFile = await realpath(childPath);
|
||||
if (!isInside(canonicalRoot, canonicalFile)) {
|
||||
throw new Error(`File resolves outside packaging root: ${childPath}`);
|
||||
}
|
||||
const relativeName = relativeArchivePath(sourceRoot, childPath);
|
||||
const name = safeArchivePath(
|
||||
prefix ? path.posix.join(prefix, relativeName) : relativeName
|
||||
);
|
||||
entries.push({ name, data: await readFile(childPath) });
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectDirectoryEntries(directory, prefix = '') {
|
||||
const details = await lstat(directory);
|
||||
if (details.isSymbolicLink() || !details.isDirectory()) {
|
||||
throw new Error(`Release input is not a real directory: ${directory}`);
|
||||
}
|
||||
const sourceRoot = path.resolve(directory);
|
||||
const canonicalRoot = await realpath(directory);
|
||||
const entries = [];
|
||||
await collectDirectoryRecursive(
|
||||
canonicalRoot,
|
||||
sourceRoot,
|
||||
sourceRoot,
|
||||
prefix,
|
||||
entries
|
||||
);
|
||||
return entries.sort((left, right) => compareText(left.name, right.name));
|
||||
}
|
||||
|
||||
async function readRequired(file) {
|
||||
try {
|
||||
return await readFile(file);
|
||||
} catch (error) {
|
||||
throw new Error(`Required release file is missing: ${file}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function existingApplicationLicense(root) {
|
||||
for (const fileName of ['LICENSE', 'LICENSE.txt', 'COPYING']) {
|
||||
const candidate = path.join(root, fileName);
|
||||
try {
|
||||
const details = await lstat(candidate);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(
|
||||
`Application licence path must be a regular file: ${candidate}`
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function assertLicenseReady(root, allowDevelopmentSmoke = false) {
|
||||
const applicationLicense = await existingApplicationLicense(root);
|
||||
if (applicationLicense) {
|
||||
const legalCode = await readFile(applicationLicense, 'utf8');
|
||||
if (
|
||||
!legalCode.includes('GNU GENERAL PUBLIC LICENSE') ||
|
||||
!legalCode.includes('Version 3, 29 June 2007') ||
|
||||
!legalCode.includes('END OF TERMS AND CONDITIONS')
|
||||
) {
|
||||
throw new Error(
|
||||
`Application licence does not contain the complete GNU GPL version 3 legal code: ${applicationLicense}`
|
||||
);
|
||||
}
|
||||
return applicationLicense;
|
||||
}
|
||||
if (allowDevelopmentSmoke) return null;
|
||||
throw new Error(
|
||||
[
|
||||
'RELEASE BLOCKED: av-tools has no application licence.',
|
||||
'The FFmpeg and dependency notices do not license the application itself.',
|
||||
'For a local, non-publishable portal test only, pass',
|
||||
'`--allow-unlicensed-development-smoke`.',
|
||||
].join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
function safePackageName(name) {
|
||||
return name.replace(/^@/, '').replaceAll('/', '--');
|
||||
}
|
||||
|
||||
async function findInstalledPackageDirectory(name, fromDirectory) {
|
||||
let current = path.resolve(fromDirectory);
|
||||
const packageSegments = name.split('/');
|
||||
while (true) {
|
||||
const candidate = path.join(current, 'node_modules', ...packageSegments);
|
||||
try {
|
||||
const details = await lstat(path.join(candidate, 'package.json'));
|
||||
if (!details.isFile()) {
|
||||
throw new Error(`Package manifest is not a file: ${candidate}`);
|
||||
}
|
||||
return await realpath(candidate);
|
||||
} catch (error) {
|
||||
if (!(error && typeof error === 'object' && error.code === 'ENOENT')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) return null;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectRuntimePackages(root, packageJson) {
|
||||
const queue = Object.keys(packageJson.dependencies ?? {})
|
||||
.sort(compareText)
|
||||
.map((name) => ({ name, fromDirectory: root }));
|
||||
const visited = new Set();
|
||||
const packages = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const dependency = queue.shift();
|
||||
const directory = await findInstalledPackageDirectory(
|
||||
dependency.name,
|
||||
dependency.fromDirectory
|
||||
);
|
||||
if (!directory) {
|
||||
throw new Error(
|
||||
`Runtime dependency is not installed: ${dependency.name}`
|
||||
);
|
||||
}
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(directory, 'package.json'), 'utf8')
|
||||
);
|
||||
const identity = `${manifest.name}@${manifest.version}`;
|
||||
if (visited.has(identity)) continue;
|
||||
visited.add(identity);
|
||||
packages.push({
|
||||
directory,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
});
|
||||
|
||||
const childNames = [
|
||||
...new Set([
|
||||
...Object.keys(manifest.dependencies ?? {}),
|
||||
...Object.keys(manifest.optionalDependencies ?? {}),
|
||||
]),
|
||||
].sort(compareText);
|
||||
for (const name of childNames) {
|
||||
const childDirectory = await findInstalledPackageDirectory(
|
||||
name,
|
||||
directory
|
||||
);
|
||||
if (childDirectory) {
|
||||
queue.push({ name, fromDirectory: directory });
|
||||
}
|
||||
}
|
||||
}
|
||||
return packages.sort((left, right) =>
|
||||
compareText(
|
||||
`${left.name}@${left.version}`,
|
||||
`${right.name}@${right.version}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectRuntimeLicenseEntries(root, packageJson) {
|
||||
const packages = await collectRuntimePackages(root, packageJson);
|
||||
const entries = [];
|
||||
for (const runtimePackage of packages) {
|
||||
const coveredBy = ffmpegLicenseCoverage.get(runtimePackage.name);
|
||||
if (coveredBy) {
|
||||
await access(path.join(root, coveredBy));
|
||||
continue;
|
||||
}
|
||||
const legalFiles = (
|
||||
await readdir(runtimePackage.directory, {
|
||||
withFileTypes: true,
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() &&
|
||||
/^(?:licen[cs]e|copying|notice)(?:\.|$)/i.test(entry.name)
|
||||
)
|
||||
.sort((left, right) => compareText(left.name, right.name));
|
||||
if (legalFiles.length === 0) {
|
||||
throw new Error(
|
||||
`Runtime dependency ${runtimePackage.name}@${runtimePackage.version} has no packaged licence file.`
|
||||
);
|
||||
}
|
||||
const packageDirectory = `${safePackageName(runtimePackage.name)}-${runtimePackage.version}`;
|
||||
for (const legalFile of legalFiles) {
|
||||
entries.push({
|
||||
name: safeArchivePath(
|
||||
`LICENSES/npm/${packageDirectory}/${legalFile.name}`
|
||||
),
|
||||
data: await readFile(
|
||||
path.join(runtimePackage.directory, legalFile.name)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function scanReleaseEntry(entry) {
|
||||
if (entry.name.endsWith('.map')) {
|
||||
throw new Error(`Source map is not approved for release: ${entry.name}`);
|
||||
}
|
||||
const lowerParts = entry.name.toLowerCase().split('/');
|
||||
if (
|
||||
lowerParts.some((part) =>
|
||||
[
|
||||
'.env',
|
||||
'.npmrc',
|
||||
'.git',
|
||||
'id_rsa',
|
||||
'id_ed25519',
|
||||
'credentials',
|
||||
].includes(part)
|
||||
) ||
|
||||
/\.(?:pem|key|p12|pfx)$/i.test(entry.name)
|
||||
) {
|
||||
throw new Error(`Credential-like release path is forbidden: ${entry.name}`);
|
||||
}
|
||||
// Exact FFmpeg vendor bytes are checked against pinned SHA-256 values. The
|
||||
// generated Emscripten glue contains upstream build paths such as
|
||||
// /home/web_user that are not paths from this repository or workstation.
|
||||
if (entry.name.startsWith('vendor/ffmpeg/')) return;
|
||||
if (!/\.(?:css|html|js|json|md|mjs|svg|txt)$/i.test(entry.name)) return;
|
||||
const text = entry.data.toString('utf8');
|
||||
if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(text)) {
|
||||
throw new Error(`Private-key material detected in ${entry.name}`);
|
||||
}
|
||||
if (/https?:\/\/[^/@\s]+:[^/@\s]+@/i.test(text)) {
|
||||
throw new Error(`Credential-bearing URL detected in ${entry.name}`);
|
||||
}
|
||||
if (
|
||||
/(?:^|[\s"'`])(?:\/home\/|\/mnt\/|\/Users\/|[A-Za-z]:\\Users\\)/m.test(text)
|
||||
) {
|
||||
throw new Error(`Local absolute path detected in ${entry.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueSortedEntries(entries) {
|
||||
entries.sort((left, right) => compareText(left.name, right.name));
|
||||
let totalBytes = 0;
|
||||
let previous;
|
||||
for (const entry of entries) {
|
||||
safeArchivePath(entry.name);
|
||||
if (entry.name === previous) {
|
||||
throw new Error(`Duplicate release path: ${entry.name}`);
|
||||
}
|
||||
previous = entry.name;
|
||||
if (entry.data.byteLength > maximumEntryBytes) {
|
||||
throw new Error(`Release entry exceeds 256 MiB: ${entry.name}`);
|
||||
}
|
||||
totalBytes += entry.data.byteLength;
|
||||
if (totalBytes > maximumArchiveBytes) {
|
||||
throw new Error('Release exceeds the 2 GiB uncompressed-size limit.');
|
||||
}
|
||||
scanReleaseEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
function manifestPath(reference, field, directoryIndex = false) {
|
||||
if (
|
||||
typeof reference !== 'string' ||
|
||||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(reference) ||
|
||||
reference.startsWith('/') ||
|
||||
reference.startsWith('//') ||
|
||||
reference.includes('?') ||
|
||||
reference.includes('#')
|
||||
) {
|
||||
throw new Error(`${field} must be a plain relative packaged path.`);
|
||||
}
|
||||
let normalized = reference.replace(/^\.\//, '');
|
||||
if (normalized === '' && directoryIndex) normalized = 'index.html';
|
||||
else if (normalized.endsWith('/') && directoryIndex) {
|
||||
normalized = `${normalized}index.html`;
|
||||
}
|
||||
return safeArchivePath(normalized);
|
||||
}
|
||||
|
||||
function validateReleaseLayout(entries, packageJson, applicationLicense) {
|
||||
const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
|
||||
const names = new Set(entries.map((entry) => entry.name));
|
||||
for (const required of [
|
||||
'index.html',
|
||||
'toolbox-app.json',
|
||||
'CHANGELOG.md',
|
||||
'SOURCE.md',
|
||||
'THIRD_PARTY_NOTICES.md',
|
||||
...requiredNativeLegalFiles,
|
||||
]) {
|
||||
if (!names.has(required)) {
|
||||
throw new Error(`Required release root file is missing: ${required}`);
|
||||
}
|
||||
}
|
||||
for (const prefix of ['LICENSES/', 'assets/', 'vendor/']) {
|
||||
if (![...names].some((name) => name.startsWith(prefix))) {
|
||||
throw new Error(`Required release directory is empty: ${prefix}`);
|
||||
}
|
||||
}
|
||||
|
||||
const manifestEntry = entries.find(
|
||||
(entry) => entry.name === 'toolbox-app.json'
|
||||
);
|
||||
const manifest = JSON.parse(manifestEntry.data.toString('utf8'));
|
||||
if (
|
||||
manifest.id !== 'de.add-ideas.av-tools' ||
|
||||
manifest.version !== packageJson.version
|
||||
) {
|
||||
throw new Error(
|
||||
'Packaged manifest id/version does not match av-tools package.json.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
applicationLicense &&
|
||||
(manifest.source?.repository !== canonicalRepository ||
|
||||
manifest.source?.license !== applicationLicenseSpdx)
|
||||
) {
|
||||
throw new Error(
|
||||
'A licensed release requires an accurate, non-placeholder manifest.source declaration.'
|
||||
);
|
||||
}
|
||||
if (!applicationLicense && manifest.source !== undefined) {
|
||||
throw new Error(
|
||||
'An unlicensed development artifact must omit manifest.source.'
|
||||
);
|
||||
}
|
||||
const references = [
|
||||
[manifest.entry, 'manifest.entry', true],
|
||||
[manifest.icon, 'manifest.icon', false],
|
||||
...(manifest.assets ?? []).map((asset, index) => [
|
||||
asset,
|
||||
`manifest.assets[${index}]`,
|
||||
false,
|
||||
]),
|
||||
];
|
||||
for (const [reference, field, directoryIndex] of references) {
|
||||
const requiredPath = manifestPath(reference, field, directoryIndex);
|
||||
if (!names.has(requiredPath)) {
|
||||
throw new Error(`${field} is missing from release: ${reference}`);
|
||||
}
|
||||
}
|
||||
|
||||
const vendorManifestPath = 'vendor/ffmpeg/0.12.10/version.json';
|
||||
const vendorManifestEntry = entriesByName.get(vendorManifestPath);
|
||||
if (!vendorManifestEntry) {
|
||||
throw new Error(
|
||||
`Required FFmpeg verification file is missing: ${vendorManifestPath}`
|
||||
);
|
||||
}
|
||||
const vendorManifest = JSON.parse(vendorManifestEntry.data.toString('utf8'));
|
||||
if (
|
||||
vendorManifest.schemaVersion !== 1 ||
|
||||
vendorManifest.coreVersion !== '0.12.10' ||
|
||||
vendorManifest.packages?.['@ffmpeg/core'] !== '0.12.10' ||
|
||||
vendorManifest.packages?.['@ffmpeg/core-mt'] !== '0.12.10' ||
|
||||
!Array.isArray(vendorManifest.assets) ||
|
||||
vendorManifest.assets.length !== 5
|
||||
) {
|
||||
throw new Error(
|
||||
'Packaged FFmpeg version.json has an unexpected shape or version.'
|
||||
);
|
||||
}
|
||||
const verifiedVendorPaths = new Set();
|
||||
for (const asset of vendorManifest.assets) {
|
||||
if (
|
||||
typeof asset.path !== 'string' ||
|
||||
!Number.isSafeInteger(asset.bytes) ||
|
||||
!/^[a-f0-9]{64}$/.test(asset.sha256)
|
||||
) {
|
||||
throw new Error(
|
||||
'Packaged FFmpeg version.json contains invalid asset metadata.'
|
||||
);
|
||||
}
|
||||
const assetPath = safeArchivePath(
|
||||
path.posix.join('vendor/ffmpeg/0.12.10', asset.path)
|
||||
);
|
||||
if (verifiedVendorPaths.has(assetPath)) {
|
||||
throw new Error(`Duplicate FFmpeg verification path: ${assetPath}`);
|
||||
}
|
||||
verifiedVendorPaths.add(assetPath);
|
||||
const packagedAsset = entriesByName.get(assetPath);
|
||||
if (
|
||||
!packagedAsset ||
|
||||
packagedAsset.data.byteLength !== asset.bytes ||
|
||||
sha256(packagedAsset.data) !== asset.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`Packaged FFmpeg asset failed byte verification: ${assetPath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const declaredVendorPaths = new Set(
|
||||
(manifest.assets ?? []).map((reference) =>
|
||||
manifestPath(reference, 'manifest.assets', false)
|
||||
)
|
||||
);
|
||||
if (
|
||||
verifiedVendorPaths.size !== declaredVendorPaths.size ||
|
||||
[...verifiedVendorPaths].some(
|
||||
(assetPath) => !declaredVendorPaths.has(assetPath)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Manifest FFmpeg assets and the verified vendor asset set do not match.'
|
||||
);
|
||||
}
|
||||
|
||||
const indexHtml = entries
|
||||
.find((entry) => entry.name === 'index.html')
|
||||
.data.toString('utf8');
|
||||
if (/(?:src|href)\s*=\s*["']\/(?!\/)/i.test(indexHtml)) {
|
||||
throw new Error('index.html contains a root-absolute asset reference.');
|
||||
}
|
||||
}
|
||||
|
||||
export function createDeterministicZip(inputEntries) {
|
||||
const entries = inputEntries.map((entry) => ({
|
||||
name: safeArchivePath(entry.name),
|
||||
data: Buffer.from(entry.data),
|
||||
}));
|
||||
assertUniqueSortedEntries(entries);
|
||||
const zippable = Object.create(null);
|
||||
for (const entry of entries) {
|
||||
zippable[entry.name] = [
|
||||
new Uint8Array(entry.data),
|
||||
{
|
||||
attrs: (0o100644 << 16) >>> 0,
|
||||
level: 6,
|
||||
mtime: zipEpoch,
|
||||
os: 3,
|
||||
},
|
||||
];
|
||||
}
|
||||
return Buffer.from(
|
||||
zipSync(zippable, {
|
||||
attrs: (0o100644 << 16) >>> 0,
|
||||
level: 6,
|
||||
mtime: zipEpoch,
|
||||
os: 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectReleaseEntries(options = {}) {
|
||||
const root = options.root ?? repositoryRoot;
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(root, 'package.json'), 'utf8')
|
||||
);
|
||||
const applicationLicense = await assertLicenseReady(
|
||||
root,
|
||||
options.allowUnlicensedDevelopmentSmoke
|
||||
);
|
||||
if (
|
||||
applicationLicense &&
|
||||
(packageJson.license !== applicationLicenseSpdx ||
|
||||
packageJson.repository?.url !== canonicalPackageRepository ||
|
||||
packageJson.homepage !== canonicalRepository)
|
||||
) {
|
||||
throw new Error(
|
||||
'Licensed release package metadata must identify GPL-3.0-or-later and the canonical av-tools repository.'
|
||||
);
|
||||
}
|
||||
const entries = await collectDirectoryEntries(path.join(root, 'dist'));
|
||||
for (const fileName of [
|
||||
'README.md',
|
||||
'CHANGELOG.md',
|
||||
'SOURCE.md',
|
||||
'THIRD_PARTY_NOTICES.md',
|
||||
]) {
|
||||
entries.push({
|
||||
name: fileName,
|
||||
data: await readRequired(path.join(root, fileName)),
|
||||
});
|
||||
}
|
||||
entries.push(
|
||||
...(await collectDirectoryEntries(path.join(root, 'LICENSES'), 'LICENSES'))
|
||||
);
|
||||
entries.push(...(await collectRuntimeLicenseEntries(root, packageJson)));
|
||||
|
||||
if (applicationLicense) {
|
||||
const applicationLicenseBytes = await readFile(applicationLicense);
|
||||
entries.push({
|
||||
name: path.basename(applicationLicense),
|
||||
data: applicationLicenseBytes,
|
||||
});
|
||||
entries.push({
|
||||
name: 'LICENSES/av-tools-LICENSE.txt',
|
||||
data: applicationLicenseBytes,
|
||||
});
|
||||
} else {
|
||||
entries.push({
|
||||
name: 'DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt',
|
||||
data: Buffer.from(
|
||||
[
|
||||
'UNLICENSED DEVELOPMENT SMOKE ARTIFACT',
|
||||
'',
|
||||
'av-tools has no application licence. This ZIP exists only to test',
|
||||
'local Toolbox Portal assembly. Do not publish or distribute it.',
|
||||
'',
|
||||
].join('\n')
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
assertUniqueSortedEntries(entries);
|
||||
validateReleaseLayout(entries, packageJson, applicationLicense);
|
||||
return { entries, packageJson, unlicensed: !applicationLicense };
|
||||
}
|
||||
|
||||
export function parseReleaseArguments(argumentsList) {
|
||||
const allowed = new Set(['--force', '--allow-unlicensed-development-smoke']);
|
||||
const unknown = argumentsList.filter((argument) => !allowed.has(argument));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown release argument: ${unknown.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
force: argumentsList.includes('--force'),
|
||||
allowUnlicensedDevelopmentSmoke: argumentsList.includes(
|
||||
'--allow-unlicensed-development-smoke'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertWritableTarget(target, force) {
|
||||
try {
|
||||
const details = await lstat(target);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`Release target is not a regular file: ${target}`);
|
||||
}
|
||||
if (!force) {
|
||||
throw new Error(
|
||||
`${path.relative(repositoryRoot, target)} exists; pass --force to replace this exact version.`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function publishReleasePair({
|
||||
archive,
|
||||
archivePath,
|
||||
checksum,
|
||||
checksumPath,
|
||||
archiveExists,
|
||||
checksumExists,
|
||||
}) {
|
||||
const releaseDirectory = path.dirname(archivePath);
|
||||
const staging = await mkdtemp(
|
||||
path.join(releaseDirectory, '.av-tools-release-')
|
||||
);
|
||||
const stagedArchive = path.join(staging, path.basename(archivePath));
|
||||
const stagedChecksum = path.join(staging, path.basename(checksumPath));
|
||||
const previousArchive = path.join(staging, 'previous-archive');
|
||||
const previousChecksum = path.join(staging, 'previous-checksum');
|
||||
let archiveBackedUp = false;
|
||||
let checksumBackedUp = false;
|
||||
let archivePublished = false;
|
||||
let checksumPublished = false;
|
||||
try {
|
||||
await writeFile(stagedArchive, archive, { flag: 'wx' });
|
||||
await writeFile(stagedChecksum, checksum, {
|
||||
encoding: 'utf8',
|
||||
flag: 'wx',
|
||||
});
|
||||
if (archiveExists) {
|
||||
await rename(archivePath, previousArchive);
|
||||
archiveBackedUp = true;
|
||||
}
|
||||
if (checksumExists) {
|
||||
await rename(checksumPath, previousChecksum);
|
||||
checksumBackedUp = true;
|
||||
}
|
||||
await rename(stagedArchive, archivePath);
|
||||
archivePublished = true;
|
||||
await rename(stagedChecksum, 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;
|
||||
} finally {
|
||||
await rm(staging, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseReleaseArguments(process.argv.slice(2));
|
||||
const { entries, packageJson, unlicensed } = await collectReleaseEntries({
|
||||
allowUnlicensedDevelopmentSmoke: options.allowUnlicensedDevelopmentSmoke,
|
||||
});
|
||||
const releaseDirectory = path.join(repositoryRoot, 'release');
|
||||
await mkdir(releaseDirectory, { recursive: true });
|
||||
const releaseDirectoryDetails = await lstat(releaseDirectory);
|
||||
if (
|
||||
releaseDirectoryDetails.isSymbolicLink() ||
|
||||
!releaseDirectoryDetails.isDirectory()
|
||||
) {
|
||||
throw new Error('release/ must be a real directory.');
|
||||
}
|
||||
|
||||
const archiveName = `av-tools-${packageJson.version}.zip`;
|
||||
const archivePath = path.join(releaseDirectory, archiveName);
|
||||
const checksumPath = `${archivePath}.sha256`;
|
||||
const archiveExists = await assertWritableTarget(archivePath, options.force);
|
||||
const checksumExists = await assertWritableTarget(
|
||||
checksumPath,
|
||||
options.force
|
||||
);
|
||||
|
||||
const archive = createDeterministicZip(entries);
|
||||
const digest = sha256(archive);
|
||||
await publishReleasePair({
|
||||
archive,
|
||||
archiveExists,
|
||||
archivePath,
|
||||
checksum: checksumLine(digest, archiveName),
|
||||
checksumExists,
|
||||
checksumPath,
|
||||
});
|
||||
|
||||
if (unlicensed) {
|
||||
console.warn(
|
||||
'WARNING: created an unlicensed development smoke artifact. Do not publish or distribute it.'
|
||||
);
|
||||
}
|
||||
console.log(`Created release/${archiveName}`);
|
||||
console.log(`Created release/${archiveName}.sha256`);
|
||||
console.log(`SHA-256 ${digest}`);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
||||
) {
|
||||
await main();
|
||||
}
|
||||
408
scripts/portal-assembly-smoke.mjs
Normal file
408
scripts/portal-assembly-smoke.mjs
Normal file
@@ -0,0 +1,408 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { sha256File, verifyChecksum } from './checksum-release.mjs';
|
||||
|
||||
const repositoryRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..'
|
||||
);
|
||||
|
||||
function consumeValue(argumentsList, index, option) {
|
||||
const value = argumentsList[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${option} requires a path value.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parsePortalArguments(argumentsList, root = repositoryRoot) {
|
||||
const packageDefaults = {
|
||||
artifact: undefined,
|
||||
portalRoot: path.join(path.dirname(root), 'references', 'toolbox-portal'),
|
||||
allowUnlicensedDevelopmentSmoke: false,
|
||||
allowKnownHeaderGap: false,
|
||||
keepTemp: false,
|
||||
};
|
||||
for (let index = 0; index < argumentsList.length; index += 1) {
|
||||
const argument = argumentsList[index];
|
||||
if (argument === '--artifact') {
|
||||
packageDefaults.artifact = path.resolve(
|
||||
root,
|
||||
consumeValue(argumentsList, index, argument)
|
||||
);
|
||||
index += 1;
|
||||
} else if (argument === '--portal-root') {
|
||||
packageDefaults.portalRoot = path.resolve(
|
||||
root,
|
||||
consumeValue(argumentsList, index, argument)
|
||||
);
|
||||
index += 1;
|
||||
} else if (argument === '--allow-unlicensed-development-smoke') {
|
||||
packageDefaults.allowUnlicensedDevelopmentSmoke = true;
|
||||
} else if (argument === '--allow-known-header-gap') {
|
||||
packageDefaults.allowKnownHeaderGap = true;
|
||||
} else if (argument === '--keep-temp') {
|
||||
packageDefaults.keepTemp = true;
|
||||
} else {
|
||||
throw new Error(`Unknown portal-smoke argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
return packageDefaults;
|
||||
}
|
||||
|
||||
function run(command, argumentsList, options = {}) {
|
||||
console.log(`\n> ${command} ${argumentsList.join(' ')}`);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, argumentsList, {
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
npm_config_audit: 'false',
|
||||
npm_config_fund: 'false',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else {
|
||||
reject(
|
||||
new Error(
|
||||
`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}.`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function capture(command, argumentsList, cwd) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, argumentsList, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(Buffer.concat(stdout).toString('utf8').trim());
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`${command} exited ${code}: ${Buffer.concat(stderr).toString('utf8').trim()}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requireRegularFile(file, label) {
|
||||
const details = await lstat(file);
|
||||
if (details.isSymbolicLink() || !details.isFile()) {
|
||||
throw new Error(`${label} is not a regular file: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function includesDevelopmentMarker(archive) {
|
||||
return archive.includes(
|
||||
Buffer.from('DEVELOPMENT-ONLY-NOT-FOR-DISTRIBUTION.txt', 'utf8')
|
||||
);
|
||||
}
|
||||
|
||||
function cspDirectives(policy) {
|
||||
return new Map(
|
||||
policy
|
||||
.split(';')
|
||||
.map((directive) => directive.trim().split(/\s+/))
|
||||
.filter((parts) => parts[0])
|
||||
.map(([name, ...values]) => [name, new Set(values)])
|
||||
);
|
||||
}
|
||||
|
||||
export function inspectPortalHeaders(configuration) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const requiredHeaders = [
|
||||
['Cross-Origin-Opener-Policy', 'same-origin'],
|
||||
['Cross-Origin-Embedder-Policy', 'require-corp'],
|
||||
['Cross-Origin-Resource-Policy', 'same-origin'],
|
||||
];
|
||||
for (const [header, value] of requiredHeaders) {
|
||||
const expression = new RegExp(
|
||||
`add_header\\s+${header}\\s+"?${value}"?\\s+always`,
|
||||
'i'
|
||||
);
|
||||
if (!expression.test(configuration)) {
|
||||
errors.push(`Missing ${header}: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cspMatch =
|
||||
/add_header\s+Content-Security-Policy\s+"([^"]+)"\s+always/i.exec(
|
||||
configuration
|
||||
);
|
||||
if (!cspMatch) {
|
||||
errors.push('Missing Content-Security-Policy header');
|
||||
} else {
|
||||
const directives = cspDirectives(cspMatch[1]);
|
||||
const requires = (directive, value) =>
|
||||
directives.get(directive)?.has(value) === true;
|
||||
if (!requires('script-src', "'wasm-unsafe-eval'")) {
|
||||
errors.push("CSP script-src lacks 'wasm-unsafe-eval'");
|
||||
}
|
||||
if (!requires('worker-src', "'self'") || !requires('worker-src', 'blob:')) {
|
||||
errors.push("CSP worker-src must contain 'self' and blob:");
|
||||
}
|
||||
if (!requires('media-src', "'self'") || !requires('media-src', 'blob:')) {
|
||||
errors.push("CSP media-src must contain 'self' and blob:");
|
||||
}
|
||||
}
|
||||
|
||||
if (!/application\/wasm\s+wasm(?:\s|;)/i.test(configuration)) {
|
||||
warnings.push(
|
||||
'The config does not explicitly map `.wasm` to application/wasm; verify the pinned image mime.types response.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!configuration.includes('vendor/ffmpeg/0\\.12\\.10') &&
|
||||
!configuration.includes('vendor/ffmpeg/0.12.10')
|
||||
) {
|
||||
warnings.push(
|
||||
'The config does not give versioned FFmpeg core assets an explicit immutable cache rule.'
|
||||
);
|
||||
}
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
async function verifyAssembly(output, appVersion, digest) {
|
||||
const catalogue = JSON.parse(
|
||||
await readFile(path.join(output, 'toolbox.catalog.json'), 'utf8')
|
||||
);
|
||||
if (
|
||||
catalogue.apps?.length !== 1 ||
|
||||
catalogue.apps[0]?.manifest !== './apps/av/toolbox-app.json'
|
||||
) {
|
||||
throw new Error('Generated catalogue does not contain the locked av app.');
|
||||
}
|
||||
const release = JSON.parse(
|
||||
await readFile(path.join(output, 'toolbox.release.json'), 'utf8')
|
||||
);
|
||||
const provenance = release.apps?.[0];
|
||||
if (
|
||||
release.apps?.length !== 1 ||
|
||||
provenance.id !== 'de.add-ideas.av-tools' ||
|
||||
provenance.version !== appVersion ||
|
||||
provenance.sha256 !== digest ||
|
||||
provenance.target !== 'av'
|
||||
) {
|
||||
throw new Error('Generated release provenance does not match the AV lock.');
|
||||
}
|
||||
|
||||
const appRoot = path.join(output, 'apps', 'av');
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(appRoot, 'toolbox-app.json'), 'utf8')
|
||||
);
|
||||
if (
|
||||
manifest.id !== 'de.add-ideas.av-tools' ||
|
||||
manifest.version !== appVersion ||
|
||||
manifest.requirements?.crossOriginIsolated !== false
|
||||
) {
|
||||
throw new Error(
|
||||
'Assembled AV manifest identity/fallback contract changed.'
|
||||
);
|
||||
}
|
||||
for (const reference of [
|
||||
'./index.html',
|
||||
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
|
||||
'./vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
|
||||
]) {
|
||||
const relative = reference.replace(/^\.\//, '');
|
||||
await requireRegularFile(
|
||||
path.join(appRoot, ...relative.split('/')),
|
||||
`Assembled ${reference}`
|
||||
);
|
||||
}
|
||||
const indexHtml = await readFile(path.join(appRoot, 'index.html'), 'utf8');
|
||||
if (/(?:src|href)\s*=\s*["']\/(?!\/)/i.test(indexHtml)) {
|
||||
throw new Error('Assembled app contains a root-absolute entry asset URL.');
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parsePortalArguments(process.argv.slice(2));
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(path.join(repositoryRoot, 'package.json'), 'utf8')
|
||||
);
|
||||
const artifact =
|
||||
options.artifact ??
|
||||
path.join(repositoryRoot, 'release', `av-tools-${packageJson.version}.zip`);
|
||||
const sidecar = `${artifact}.sha256`;
|
||||
await requireRegularFile(artifact, 'AV release artifact');
|
||||
await requireRegularFile(sidecar, 'AV release checksum');
|
||||
const digest = await verifyChecksum(artifact, sidecar);
|
||||
const artifactBytes = await readFile(artifact);
|
||||
const markedUnlicensed = includesDevelopmentMarker(artifactBytes);
|
||||
if (markedUnlicensed && !options.allowUnlicensedDevelopmentSmoke) {
|
||||
throw new Error(
|
||||
'The ZIP is marked unlicensed development-only. Pass `--allow-unlicensed-development-smoke` for local assembly testing.'
|
||||
);
|
||||
}
|
||||
|
||||
const portalRootDetails = await lstat(options.portalRoot);
|
||||
if (portalRootDetails.isSymbolicLink() || !portalRootDetails.isDirectory()) {
|
||||
throw new Error(
|
||||
`Portal reference must be a real Git directory: ${options.portalRoot}`
|
||||
);
|
||||
}
|
||||
const portalRevision = await capture(
|
||||
'git',
|
||||
['rev-parse', 'HEAD'],
|
||||
options.portalRoot
|
||||
);
|
||||
const temporaryRoot = await mkdtemp('/tmp/av-tools-portal-smoke-');
|
||||
if (!temporaryRoot.startsWith('/tmp/av-tools-portal-smoke-')) {
|
||||
throw new Error(`Unexpected temporary path: ${temporaryRoot}`);
|
||||
}
|
||||
const portalClone = path.join(temporaryRoot, 'toolbox-portal');
|
||||
let successful = false;
|
||||
try {
|
||||
await run(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--no-local',
|
||||
'--quiet',
|
||||
'--no-checkout',
|
||||
options.portalRoot,
|
||||
portalClone,
|
||||
],
|
||||
{ cwd: temporaryRoot }
|
||||
);
|
||||
await run('git', ['checkout', '--quiet', '--detach', portalRevision], {
|
||||
cwd: portalClone,
|
||||
});
|
||||
const clonedRevision = await capture(
|
||||
'git',
|
||||
['rev-parse', 'HEAD'],
|
||||
portalClone
|
||||
);
|
||||
if (clonedRevision !== portalRevision) {
|
||||
throw new Error('Temporary Portal clone revision changed unexpectedly.');
|
||||
}
|
||||
|
||||
const smokeArtifacts = path.join(portalClone, 'smoke-artifacts');
|
||||
await mkdir(smokeArtifacts);
|
||||
const copiedArtifact = path.join(smokeArtifacts, path.basename(artifact));
|
||||
await copyFile(artifact, copiedArtifact);
|
||||
await copyFile(sidecar, `${copiedArtifact}.sha256`);
|
||||
|
||||
const templatePath = path.join(
|
||||
portalClone,
|
||||
'release',
|
||||
'toolbox.lock.example.json'
|
||||
);
|
||||
const lock = JSON.parse(await readFile(templatePath, 'utf8'));
|
||||
const portalPackage = JSON.parse(
|
||||
await readFile(path.join(portalClone, 'package.json'), 'utf8')
|
||||
);
|
||||
lock.releaseVersion = `${packageJson.version}-smoke.0`;
|
||||
lock.portalVersion = portalPackage.version;
|
||||
lock.apps = [
|
||||
{
|
||||
id: 'de.add-ideas.av-tools',
|
||||
version: packageJson.version,
|
||||
artifact: `../smoke-artifacts/${path.basename(artifact)}`,
|
||||
sha256: digest,
|
||||
target: 'av',
|
||||
},
|
||||
];
|
||||
const lockPath = path.join(
|
||||
portalClone,
|
||||
'release',
|
||||
'av-tools.smoke.lock.json'
|
||||
);
|
||||
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`, {
|
||||
flag: 'wx',
|
||||
});
|
||||
|
||||
await run('npm', ['ci'], { cwd: portalClone });
|
||||
await run('npm', ['test'], { cwd: portalClone });
|
||||
await run('npm', ['run', 'build'], { cwd: portalClone });
|
||||
const output = path.join(portalClone, 'build', 'toolbox-av-smoke');
|
||||
await run(
|
||||
'npm',
|
||||
[
|
||||
'run',
|
||||
'assemble',
|
||||
'--',
|
||||
'--lock',
|
||||
'release/av-tools.smoke.lock.json',
|
||||
'--portal-dist',
|
||||
'dist',
|
||||
'--output',
|
||||
'build/toolbox-av-smoke',
|
||||
'--archive',
|
||||
'build/add-ideas-toolbox-av-smoke.zip',
|
||||
],
|
||||
{ cwd: portalClone }
|
||||
);
|
||||
await verifyAssembly(output, packageJson.version, digest);
|
||||
|
||||
const headerInspection = inspectPortalHeaders(
|
||||
await readFile(path.join(portalClone, 'deploy', 'nginx.conf'), 'utf8')
|
||||
);
|
||||
for (const warning of headerInspection.warnings) {
|
||||
console.warn(`Portal header warning: ${warning}`);
|
||||
}
|
||||
if (headerInspection.errors.length > 0) {
|
||||
const message = `Portal header gaps: ${headerInspection.errors.join('; ')}`;
|
||||
if (!options.allowKnownHeaderGap) throw new Error(message);
|
||||
console.warn(
|
||||
`KNOWN HEADER GAP ACCEPTED FOR DEVELOPMENT SMOKE: ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
successful = true;
|
||||
console.log('\nPortal assembly smoke passed');
|
||||
console.log(`Portal revision: ${portalRevision}`);
|
||||
console.log(`AV artifact SHA-256: ${digest}`);
|
||||
console.log('Assembled target: apps/av/');
|
||||
console.log(
|
||||
'Validated: checksum, extraction, manifest identity/version, catalogue, ST/MT assets, relative entry URLs and ST fallback contract.'
|
||||
);
|
||||
} finally {
|
||||
if (options.keepTemp) {
|
||||
console.log(
|
||||
`${successful ? 'Kept' : 'Failed; kept'} temporary Portal smoke directory: ${temporaryRoot}`
|
||||
);
|
||||
} else {
|
||||
await rm(temporaryRoot, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
||||
) {
|
||||
await main();
|
||||
}
|
||||
80
scripts/verify-ffmpeg-assets.mjs
Normal file
80
scripts/verify-ffmpeg-assets.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repositoryRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const expectedVersion = '0.12.10';
|
||||
const outputRoot = join(
|
||||
repositoryRoot,
|
||||
'public',
|
||||
'vendor',
|
||||
'ffmpeg',
|
||||
expectedVersion
|
||||
);
|
||||
const manifestPath = join(outputRoot, 'version.json');
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
|
||||
if (
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.coreVersion !== expectedVersion ||
|
||||
!Array.isArray(manifest.assets) ||
|
||||
manifest.assets.length !== 5
|
||||
) {
|
||||
throw new Error(`Invalid FFmpeg asset manifest: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const expectedPaths = new Set([
|
||||
'mt/ffmpeg-core.js',
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
'st/ffmpeg-core.js',
|
||||
'st/ffmpeg-core.wasm',
|
||||
]);
|
||||
const expectedHashes = new Map([
|
||||
[
|
||||
'st/ffmpeg-core.js',
|
||||
'67a48f11645f85439f3fde4f2119042c16b374b910206b7a7a24f342e28dcae3',
|
||||
],
|
||||
[
|
||||
'st/ffmpeg-core.wasm',
|
||||
'9f57947a5bd530d8f00c5b3f2cb2a3492faa7e5d823315342d6a8656d0a6b7b7',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.js',
|
||||
'270a2e6ff945e173238610669a3f7132df5f9c52698a9bf708cf5c2ab6bda0de',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.wasm',
|
||||
'be2c97605366b78f3f13e21b52e81a55a79e1f29c133b03a68ec187b1a2ec41a',
|
||||
],
|
||||
[
|
||||
'mt/ffmpeg-core.worker.js',
|
||||
'f77898d631dc010b45c29c23cb4379c611a7d7b131bf591d08a656bb729a4ca3',
|
||||
],
|
||||
]);
|
||||
|
||||
for (const asset of manifest.assets) {
|
||||
if (!expectedPaths.delete(asset.path)) {
|
||||
throw new Error(`Unexpected or duplicate FFmpeg asset: ${asset.path}`);
|
||||
}
|
||||
const bytes = await readFile(join(outputRoot, asset.path));
|
||||
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||
if (
|
||||
asset.bytes !== bytes.byteLength ||
|
||||
asset.sha256 !== digest ||
|
||||
expectedHashes.get(asset.path) !== digest
|
||||
) {
|
||||
throw new Error(`FFmpeg asset verification failed: ${asset.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedPaths.size > 0) {
|
||||
throw new Error(
|
||||
`FFmpeg assets are missing: ${[...expectedPaths].join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Verified 5 FFmpeg ${expectedVersion} assets in ${relative(repositoryRoot, outputRoot)}`
|
||||
);
|
||||
Reference in New Issue
Block a user