feat: publish av-tools 0.1.0
This commit is contained in:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user