#!/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; } }