240 lines
7.3 KiB
JavaScript
240 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from 'node:crypto';
|
|
import { createReadStream } from 'node:fs';
|
|
import { access, readFile, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
import { parseReleaseLock } from './release-lock.mjs';
|
|
|
|
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
const HEX_HASH = /^[a-f0-9]{64}$/;
|
|
|
|
function usage() {
|
|
return `Usage: node scripts/update-release-lock.mjs --app-id ID --app-version VERSION --artifact URL --sha256 HEX --release-version SEMVER [options]
|
|
|
|
Options:
|
|
--lock PATH Path to release lock (default: release/toolbox.lock.json)
|
|
--portal-version SEMVER Optional portal package override
|
|
--app-id ID App id in lock (for example de.add-ideas.translator-tools)
|
|
--app-version VERSION App semantic version
|
|
--artifact URL Artifact URL/path/file: URL
|
|
--sha256 HEX Optional artifact checksum; required for remote artifacts
|
|
--release-version VERSION New toolbox release version
|
|
--assemble Run npm run build and npm run assemble after patching
|
|
--force Pass --force to assemble command
|
|
--help Show this message
|
|
`;
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
if (argv.includes('--help')) {
|
|
console.log(usage());
|
|
process.exit(0);
|
|
}
|
|
|
|
const args = new Map();
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const option = argv[index];
|
|
if (!option.startsWith('--')) {
|
|
throw new Error(`Unknown argument: ${option}`);
|
|
}
|
|
|
|
if (option === '--assemble' || option === '--force') {
|
|
args.set(option, true);
|
|
continue;
|
|
}
|
|
|
|
const value = argv[index + 1];
|
|
if (value === undefined) {
|
|
throw new Error(`${option} requires a value.`);
|
|
}
|
|
args.set(option, value);
|
|
index += 1;
|
|
}
|
|
|
|
return {
|
|
lockFile: args.get('--lock') ?? 'release/toolbox.lock.json',
|
|
appId: args.get('--app-id'),
|
|
appVersion: args.get('--app-version'),
|
|
artifact: args.get('--artifact'),
|
|
sha256: args.get('--sha256'),
|
|
releaseVersion: args.get('--release-version'),
|
|
portalVersion: args.get('--portal-version'),
|
|
assemble: args.get('--assemble') === true,
|
|
forceAssemble: args.get('--force') === true,
|
|
};
|
|
}
|
|
|
|
function bumpPatch(version) {
|
|
const [major, minor, patchAndSuffix] = version.split('.');
|
|
const [patch] = patchAndSuffix.split('-');
|
|
const nextPatch = Number.parseInt(patch, 10) + 1;
|
|
return `${major}.${minor}.${nextPatch}`;
|
|
}
|
|
|
|
async function sha256ForPath(filePath) {
|
|
await access(filePath);
|
|
const hash = createHash('sha256');
|
|
const input = createReadStream(filePath);
|
|
await new Promise((resolve, reject) => {
|
|
input.on('data', (chunk) => hash.update(chunk));
|
|
input.on('error', reject);
|
|
input.on('end', resolve);
|
|
});
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
async function computeRemoteSha256(url) {
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`Unable to download artifact (${response.status} ${response.statusText}).`);
|
|
}
|
|
|
|
if (!response.body) {
|
|
throw new Error('Artifact download did not provide a response body.');
|
|
}
|
|
|
|
const hash = createHash('sha256');
|
|
const reader = response.body.getReader();
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
hash.update(Buffer.from(value));
|
|
}
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
function resolveArtifactPath(artifact, lockDirectory) {
|
|
if (/^file:/i.test(artifact)) return new URL(artifact).pathname;
|
|
if (/^https?:/i.test(artifact)) return null;
|
|
return path.resolve(lockDirectory, artifact);
|
|
}
|
|
|
|
function toShaIfNeeded(hash) {
|
|
if (!hash) return null;
|
|
const normalized = hash.toLowerCase();
|
|
if (!HEX_HASH.test(normalized)) {
|
|
throw new Error('Provided --sha256 is not a valid 64 hex hash.');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function command(cwd) {
|
|
return (name, args) =>
|
|
new Promise((resolve, reject) => {
|
|
const child = spawn(name, args, {
|
|
cwd,
|
|
stdio: 'inherit',
|
|
shell: true,
|
|
});
|
|
|
|
child.on('error', reject);
|
|
child.on('exit', (code) =>
|
|
code === 0
|
|
? resolve(undefined)
|
|
: reject(new Error(`${name} ${args.join(' ')} failed with code ${code}`))
|
|
);
|
|
});
|
|
}
|
|
|
|
async function run() {
|
|
const options = parseArguments(process.argv.slice(2));
|
|
if (
|
|
!options.appId ||
|
|
!options.appVersion ||
|
|
!options.artifact ||
|
|
!options.releaseVersion
|
|
) {
|
|
throw new Error(`Missing required options.
|
|
|
|
${usage()}`);
|
|
}
|
|
if (!SEMVER.test(options.releaseVersion)) {
|
|
throw new Error(`--release-version must be semver: ${options.releaseVersion}`);
|
|
}
|
|
if (!SEMVER.test(options.appVersion)) {
|
|
throw new Error(`--app-version must be semver: ${options.appVersion}`);
|
|
}
|
|
|
|
const lockPath = path.resolve(options.lockFile);
|
|
const lockDir = path.dirname(lockPath);
|
|
const lockJson = JSON.parse(await readFile(lockPath, 'utf8'));
|
|
const lock = parseReleaseLock(lockJson);
|
|
const nextReleaseVersion = options.releaseVersion ?? bumpPatch(lock.releaseVersion);
|
|
if (!SEMVER.test(nextReleaseVersion)) {
|
|
throw new Error(`Invalid release version: ${nextReleaseVersion}`);
|
|
}
|
|
|
|
const appIndex = lock.apps.findIndex((app) => app.id === options.appId);
|
|
if (appIndex < 0) {
|
|
throw new Error(`App id not found in lock: ${options.appId}`);
|
|
}
|
|
|
|
const artifactPath = resolveArtifactPath(options.artifact, lockDir);
|
|
let sha256 = toShaIfNeeded(options.sha256);
|
|
if (!sha256) {
|
|
if (artifactPath) {
|
|
sha256 = await sha256ForPath(artifactPath);
|
|
} else {
|
|
sha256 = await computeRemoteSha256(options.artifact);
|
|
}
|
|
}
|
|
if (!HEX_HASH.test(sha256)) {
|
|
throw new Error('Computed checksum is malformed.');
|
|
}
|
|
|
|
const updatedApps = [...lock.apps];
|
|
const previous = updatedApps[appIndex];
|
|
updatedApps[appIndex] = {
|
|
...previous,
|
|
version: options.appVersion,
|
|
artifact: options.artifact,
|
|
sha256,
|
|
};
|
|
|
|
const nextLock = {
|
|
...lock,
|
|
releaseVersion: nextReleaseVersion,
|
|
apps: updatedApps,
|
|
};
|
|
if (options.portalVersion) {
|
|
if (!SEMVER.test(options.portalVersion))
|
|
throw new Error(`--portal-version must be semver: ${options.portalVersion}`);
|
|
nextLock.portalVersion = options.portalVersion;
|
|
}
|
|
|
|
parseReleaseLock(nextLock);
|
|
await writeFile(lockPath, `${JSON.stringify(nextLock, null, 2)}\n`, 'utf8');
|
|
console.log(`Updated ${path.relative(process.cwd(), lockPath)} (${options.appId})`);
|
|
|
|
if (!options.assemble) return;
|
|
const runCommand = command(process.cwd());
|
|
await runCommand('npm', ['run', 'build']);
|
|
const archive = `build/add-ideas-toolbox-${nextReleaseVersion}.zip`;
|
|
const assembleArgs = [
|
|
'run',
|
|
'assemble',
|
|
'--',
|
|
'--lock',
|
|
options.lockFile,
|
|
'--portal-dist',
|
|
'dist',
|
|
'--output',
|
|
'build/toolbox',
|
|
'--archive',
|
|
archive,
|
|
];
|
|
if (options.forceAssemble) assembleArgs.push('--force');
|
|
await runCommand('npm', assembleArgs);
|
|
console.log(`Assembled release -> ${archive}`);
|
|
const releaseChecksum = `${archive}.sha256`;
|
|
const releaseSha256 = await sha256ForPath(archive);
|
|
await writeFile(releaseChecksum, `${releaseSha256} ${path.basename(archive)}\n`, 'utf8');
|
|
console.log(`Assembled release checksum: ${releaseSha256}`);
|
|
}
|
|
|
|
run().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
});
|