196 lines
15 KiB
JavaScript
196 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Maintainer-only producer. Consumers download the resulting native bundle.
|
|
import { spawn } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync, realpathSync, renameSync, rmSync, statSync, copyFileSync, chmodSync, openSync, closeSync, readdirSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { parse } from "yaml";
|
|
import { prepareBundle, sha256 } from "./release-bundle.mjs";
|
|
import { dockerCredentials, dockerHub } from "./release-registry.mjs";
|
|
import { giteaHosting } from "./release-hosting.mjs";
|
|
import { publishVerifiedRelease } from "./release-publication.mjs";
|
|
|
|
const repositoryRoot = resolve(import.meta.dirname, "../..");
|
|
export function optionsFromArgs(args) {
|
|
const values = {};
|
|
for (let i = 0; i < args.length; i += 2) {
|
|
if (!['--revision', '--version', '--namespace', '--platforms', '--output', '--repository'].includes(args[i]) || !args[i + 1] || values[args[i]]) throw new Error("Usage: --revision REF --version VERSION --namespace DOCKER_HUB_NAMESPACE --platforms linux/amd64 --output NEW_OR_MATCHING_DIRECTORY [--repository HTTPS_GITEA_REPO]");
|
|
values[args[i]] = args[i + 1];
|
|
}
|
|
if (!values['--revision'] || values['--revision'].startsWith('-') || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(values['--version'] ?? '') || !/^[a-z0-9][a-z0-9_-]{1,38}$/.test(values['--namespace'] ?? '') || !values['--output']) throw new Error("Supply an explicit source revision, semantic release version, Docker Hub namespace and output directory.");
|
|
const platforms = (values['--platforms'] ?? '').split(',');
|
|
if (!platforms.length || new Set(platforms).size !== platforms.length || platforms.some((p) => !['linux/amd64', 'linux/arm64'].includes(p))) throw new Error("Select explicit Linux image platforms; begin with linux/amd64 for Windows/WSL2 and Omarchy.");
|
|
const repository = values['--repository'] ?? 'https://git.tylconsulting.it/mptyl/ThothII';
|
|
const url = new URL(repository);
|
|
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url.pathname)) throw new Error("Use a credential-free HTTPS Gitea owner/repository URL.");
|
|
return { revision: values['--revision'], version: values['--version'], namespace: values['--namespace'], platforms: platforms.sort(), output: resolve(values['--output']), repository };
|
|
}
|
|
export function commandRunner(logs) {
|
|
let sequence = 0;
|
|
return async (command, args, { cwd = repositoryRoot, input = '', env = process.env, quiet = false, timeout = 120_000 } = {}) => {
|
|
const log = join(logs, `${++sequence}-${basename(command)}.log`);
|
|
return new Promise((accept, reject) => {
|
|
if (process.platform === 'win32') { reject(new Error('Run the release producer on Linux, WSL2 or macOS.')); return; }
|
|
const child = spawn(command, args, { cwd, env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
const stdout = [], stderr = []; let size = 0, overflow = false, settled = false, reapTimer;
|
|
const terminate = () => {
|
|
if (overflow) return;
|
|
overflow = true;
|
|
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
|
// An escaped descendant must never retain our pipes indefinitely.
|
|
reapTimer = setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); finish(-1); }, 500);
|
|
};
|
|
const timer = setTimeout(terminate, timeout);
|
|
const collect = (chunks) => (data) => { size += data.length; if (size > 64 * 2 ** 20) terminate(); else chunks.push(data); };
|
|
child.stdout.on('data', collect(stdout)); child.stderr.on('data', collect(stderr));
|
|
child.stdin.on('error', () => {}); child.stdin.end(input);
|
|
child.on('error', () => { settled = true; clearTimeout(timer); clearTimeout(reapTimer); reject(new Error(`Required maintainer command ${basename(command)} is unavailable.`)); });
|
|
function finish(code) {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer); clearTimeout(reapTimer);
|
|
if (!quiet) writeFileSync(log, Buffer.concat([...stdout, ...stderr]), { mode: 0o600 });
|
|
if (code !== 0 || overflow) reject(new Error(`${basename(command)} failed${quiet ? '.' : `; inspect private log ${log}`}`));
|
|
else accept(Buffer.concat(stdout).toString());
|
|
}
|
|
child.on('close', finish);
|
|
});
|
|
};
|
|
}
|
|
function acquireOutput(output) {
|
|
if (!existsSync(output)) mkdirSync(output, { mode: 0o700 });
|
|
if (realpathSync(output) !== output || !statSync(output).isDirectory() || (statSync(output).mode & 0o077)) throw new Error("Use a canonical owner-only output directory.");
|
|
if (!existsSync(join(output, 'publication-state.json')) && readdirSync(output).length) throw new Error("Choose an empty output directory or the matching previous publication directory.");
|
|
const lock = join(output, '.publisher.lock');
|
|
if (existsSync(lock)) {
|
|
const pid = Number(readFileSync(lock, 'utf8'));
|
|
if (!Number.isInteger(pid) || pid <= 0) throw new Error("Inspect the incomplete publisher lock before retrying.");
|
|
let alive = true;
|
|
try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
|
|
if (alive) throw new Error("Another publisher owns this output directory.");
|
|
rmSync(lock);
|
|
}
|
|
const fd = openSync(lock, 'wx', 0o600); writeFileSync(fd, String(process.pid)); closeSync(fd);
|
|
return () => rmSync(lock, { force: true });
|
|
}
|
|
export async function publishInstallation(options) {
|
|
const unlock = acquireOutput(options.output);
|
|
const logs = join(options.output, 'logs'); mkdirSync(logs, { recursive: true, mode: 0o700 });
|
|
const run = commandRunner(logs);
|
|
let worktree, temporary;
|
|
try {
|
|
const revision = (await run('git', ['rev-parse', '--verify', `${options.revision}^{commit}`], { quiet: true })).trim();
|
|
if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error("Source revision is not a commit.");
|
|
const identity = { revision, version: options.version, namespace: options.namespace, platforms: options.platforms, repository: options.repository };
|
|
const statePath = join(options.output, 'publication-state.json');
|
|
let state = { identity };
|
|
if (existsSync(statePath)) {
|
|
state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
if (JSON.stringify(state.identity) !== JSON.stringify(identity)) throw new Error("Output directory belongs to a different release; choose a new directory.");
|
|
}
|
|
const save = () => { const temp = statePath + '.tmp'; writeFileSync(temp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); renameSync(temp, statePath); };
|
|
save();
|
|
console.log(`Release ${identity.version}: ${identity.namespace}, ${identity.platforms.join(',')}, source ${revision}`);
|
|
await run('docker', ['info', '--format', '{{.OSType}}']);
|
|
await run('docker', ['buildx', 'version']);
|
|
const registry = dockerHub(await dockerCredentials(run));
|
|
const hosting = await giteaHosting({ run, repository: options.repository, identity, body: `Installer prerelease for ${identity.platforms.join(', ')}.\n\nImages: docker.io/${identity.namespace}/thothii-core:${identity.version} and docker.io/${identity.namespace}/thothii-frontend:${identity.version}.\n\nDownload the native operator bundle and SHA256SUMS.txt below. This release supplies images, document validation and preflight; complete non-interactive setup and Windows/Omarchy/macOS acceptance are subsequent tickets. No example databases, credentials or user workspace data are included.` });
|
|
async function prepare() {
|
|
if (state.assets) {
|
|
const names = [...identity.platforms.map((platform) => `thothii-${identity.version}-${platform.replace('/', '-')}.tar.gz`), 'SHA256SUMS.txt'];
|
|
if (state.assets.length !== names.length) throw new Error("Cached artifact set is incomplete.");
|
|
for (const asset of state.assets) if (!names.includes(asset.name) || asset.path !== join(options.output, asset.name) || !existsSync(asset.path) || sha256(readFileSync(asset.path)) !== asset.sha256) throw new Error("Previously built release artifact changed; do not overwrite an immutable version.");
|
|
for (const [platform, images] of Object.entries(state.images)) for (const reference of Object.values(images)) {
|
|
const [repository, digest] = reference.replace(/^docker.io\//, '').split('@');
|
|
await registry.inspect(repository, digest, platform, { anonymous: true });
|
|
}
|
|
return state.assets;
|
|
}
|
|
temporary = realpathSync(mkdtempSync(join(tmpdir(), 'thothii-release-')));
|
|
worktree = join(temporary, 'source');
|
|
await run('git', ['worktree', 'add', '--detach', worktree, revision]);
|
|
const sourceCompose = parse(readFileSync(join(worktree, 'compose.yaml'), 'utf8'));
|
|
for (const role of ['core', 'frontend']) {
|
|
console.log(`Preparing public repository ${identity.namespace}/thothii-${role}`);
|
|
await registry.ensurePublic(identity.namespace, `thothii-${role}`);
|
|
let existing = null;
|
|
for (const platform of identity.platforms) {
|
|
const image = await registry.inspect(`${identity.namespace}/thothii-${role}`, identity.version, platform, { allowMissing: true });
|
|
if (image && (image.labels['org.opencontainers.image.revision'] !== revision || image.labels['org.opencontainers.image.version'] !== identity.version)) throw new Error("Image tag already belongs to another immutable build; select a new release version.");
|
|
existing = existing || image;
|
|
}
|
|
if (!existing) {
|
|
console.log(`Building and publishing ${role} (${identity.platforms.join(', ')})`);
|
|
await run('docker', ['buildx', 'build', '--platform', identity.platforms.join(','), '--file', `docker/${role}.Dockerfile`, '--tag', `docker.io/${identity.namespace}/thothii-${role}:${identity.version}`, '--build-arg', `IMAGE_VERSION=${identity.version}`, '--label', `org.opencontainers.image.revision=${revision}`, '--label', `org.opencontainers.image.source=${identity.repository}`, '--provenance=false', '--sbom=false', '--push', '.'], { cwd: worktree, timeout: 45 * 60_000 });
|
|
}
|
|
}
|
|
state.images = {};
|
|
for (const platform of identity.platforms) {
|
|
const images = {};
|
|
for (const role of ['core', 'frontend']) images[role] = (await registry.inspect(`${identity.namespace}/thothii-${role}`, identity.version, platform, { anonymous: true })).reference;
|
|
for (const [role, service] of [['catalog', 'catalog-db'], ['qdrant', 'qdrant'], ['embedding', 'embedding']]) {
|
|
const [named, digest] = sourceCompose.services[service].image.split('@');
|
|
let repository = named.replace(/:[^/:]+$/, '').replace(/^docker.io\//, '');
|
|
if (!repository.includes('/')) repository = 'library/' + repository;
|
|
images[role] = (await registry.inspect(repository, digest, platform, { anonymous: true })).reference;
|
|
}
|
|
state.images[platform] = images;
|
|
}
|
|
save();
|
|
console.log('Building native operator bundles from the selected source');
|
|
await run('npm', ['ci'], { cwd: join(worktree, 'backend'), timeout: 10 * 60_000 });
|
|
const sourceTime = (await run('git', ['show', '-s', '--format=%cI', revision], { quiet: true })).trim();
|
|
const targets = identity.platforms.map((platform) => platform.replace('/', '-'));
|
|
await run('node', [join(repositoryRoot, 'backend/scripts/build-workspace-tools.mjs'), ...targets], { cwd: join(worktree, 'backend'), env: { ...process.env, THT_BUILD_SOURCE_ROOT: worktree, THT_BUILD_VERSION: identity.version, THT_BUILD_TIME: sourceTime }, timeout: 10 * 60_000 });
|
|
const assets = [];
|
|
for (const platform of identity.platforms) {
|
|
const target = platform.replace('/', '-');
|
|
const name = `thothii-${identity.version}-${target}`;
|
|
const bundle = join(options.output, name);
|
|
if (existsSync(bundle)) rmSync(bundle, { recursive: true }); // owned staging, never an installed runtime
|
|
mkdirSync(bundle);
|
|
prepareBundle({ source: worktree, destination: bundle, platform, version: identity.version, revision, images: state.images[platform] });
|
|
mkdirSync(join(bundle, 'bin'));
|
|
for (const executable of ['tht', 'tht-workspace-documents']) {
|
|
copyFileSync(join(worktree, 'dist/workspace-tools', target, executable), join(bundle, 'bin', executable));
|
|
chmodSync(join(bundle, 'bin', executable), 0o755);
|
|
}
|
|
// Resolve source-independent resource/config shape without any operator credentials.
|
|
await run('docker', ['compose', '-f', join(bundle, 'compose.yaml'), '-f', join(bundle, 'deploy/compose.local.yaml'), 'config', '--no-interpolate', '--no-env-resolution', '--format', 'json']);
|
|
const archive = join(options.output, name + '.tar.gz');
|
|
await run('tar', ['-czf', archive, '-C', options.output, name]);
|
|
assets.push({ name: basename(archive), path: archive, bytes: statSync(archive).size, sha256: sha256(readFileSync(archive)) });
|
|
}
|
|
const sums = join(options.output, 'SHA256SUMS.txt');
|
|
writeFileSync(sums, assets.map((asset) => `${asset.sha256} ${asset.name}\n`).join(''));
|
|
assets.push({ name: 'SHA256SUMS.txt', path: sums, bytes: statSync(sums).size, sha256: sha256(readFileSync(sums)) });
|
|
// An empty Docker configuration proves the consumer can pull without publisher credentials.
|
|
const publicConfig = join(temporary, 'public-docker'); mkdirSync(publicConfig);
|
|
for (const [platform, images] of Object.entries(state.images)) {
|
|
for (const reference of Object.values(images)) {
|
|
console.log(`Verifying anonymous pull ${reference.split('@')[0]} (${platform})`);
|
|
await run('docker', ['--config', publicConfig, 'pull', '--platform', platform, reference], { timeout: 20 * 60_000 });
|
|
}
|
|
console.log(`Smoke checking published images (${platform})`);
|
|
await run('docker', ['run', '--rm', '--platform', platform, '--network', 'none', '--entrypoint', '/bin/sh', images.core, '-ec', 'test "$(pi --version)" = "$PI_VERSION"; tht --help >/dev/null; test -f /app/backend/dist/catalog/migrate.js; test -x /app/docker/workspace-maintenance-entrypoint.sh; test -f /app/docker/catalog-migrate.sh; test ! -e /run/secrets/thothii.secrets'], { timeout: 5 * 60_000 });
|
|
await run('docker', ['run', '--rm', '--platform', platform, '--network', 'none', '--entrypoint', '/usr/local/bin/frontend-config-smoke', images.frontend], { timeout: 60_000 });
|
|
}
|
|
state.assets = assets; save();
|
|
return assets;
|
|
}
|
|
const published = await publishVerifiedRelease({ hosting, prepare });
|
|
state.releaseURL = published.html_url; state.complete = true; save();
|
|
console.log(`Published and verified: ${published.html_url}`);
|
|
return published;
|
|
} finally {
|
|
if (worktree && existsSync(worktree)) await run('git', ['worktree', 'remove', '--force', worktree]).catch(() => {});
|
|
if (temporary && !existsSync(worktree ?? '')) rmSync(temporary, { recursive: true, force: true });
|
|
unlock();
|
|
}
|
|
}
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
try { await publishInstallation(optionsFromArgs(process.argv.slice(2))); }
|
|
catch (error) { console.error(error instanceof SyntaxError ? 'Invalid release metadata; no secret values are printed.' : error.message); process.exitCode = 1; }
|
|
}
|