feat: publish verified installation images and native release bundles

This commit is contained in:
Codex
2026-09-28 17:46:09 +02:00
parent 55f3569e55
commit ec0421e9fd
14 changed files with 676 additions and 5 deletions
+7 -5
View File
@@ -5,8 +5,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const backend = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const repository = resolve(backend, "..");
const repository = process.env.THT_BUILD_SOURCE_ROOT ? resolve(process.env.THT_BUILD_SOURCE_ROOT) : resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const backend = join(repository, "backend");
const native = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch === "x64" ? "amd64" : process.arch}`;
const targets = {
"windows-amd64": ["windows", "amd64", "bun-windows-x64"],
@@ -28,7 +28,9 @@ function run(command, args, cwd = backend, env = process.env) {
const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repository, encoding: "utf8" });
if (revision.status !== 0) throw new Error("Cannot read build revision");
const commit = revision.stdout.trim();
const buildTime = new Date().toISOString();
const buildTime = process.env.THT_BUILD_TIME ?? new Date().toISOString();
const releaseVersion = process.env.THT_BUILD_VERSION ?? "0.0.0-dev";
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(releaseVersion) || !Number.isFinite(Date.parse(buildTime))) throw new Error("Invalid build identity");
const module = "github.com/aritmolab/thothii/tools/tht/internal/version";
const bunPackage = JSON.parse(readFileSync(join(backend, "node_modules", "bun", "package.json"), "utf8"));
const bun = join(backend, "node_modules", "bun", bunPackage.bin.bun);
@@ -39,9 +41,9 @@ for (const target of selected) {
const extension = os === "windows" ? ".exe" : "";
const names = [`tht${extension}`, `tht-workspace-documents${extension}`];
run(bun, ["build", "src/workspace-documents-cli.ts", "--compile", `--target=${bunTarget}`, "--outfile", join(output, names[1])]);
run("go", ["build", "-trimpath", "-ldflags", `-s -w -X ${module}.semanticVersion=0.0.0-dev -X ${module}.commit=${commit} -X ${module}.buildTime=${buildTime}`, "-o", join(output, names[0]), "./cmd/tht"], join(repository, "tools", "tht"), { ...process.env, CGO_ENABLED: "0", GOOS: os, GOARCH: arch });
run("go", ["build", "-trimpath", "-ldflags", `-s -w -X ${module}.semanticVersion=${releaseVersion} -X ${module}.commit=${commit} -X ${module}.buildTime=${buildTime}`, "-o", join(output, names[0]), "./cmd/tht"], join(repository, "tools", "tht"), { ...process.env, CGO_ENABLED: "0", GOOS: os, GOARCH: arch });
const hashes = names.map((name) => `${createHash("sha256").update(readFileSync(join(output, name))).digest("hex")} ${name}\n`).join("");
writeFileSync(join(output, "SHA256SUMS"), hashes);
writeFileSync(join(output, "build.json"), JSON.stringify({ commit, buildTime, target, bun: JSON.parse(readFileSync(join(backend, "package.json"), "utf8")).devDependencies.bun }, null, 2) + "\n");
writeFileSync(join(output, "build.json"), JSON.stringify({ version: releaseVersion, commit, buildTime, target, bun: JSON.parse(readFileSync(join(backend, "package.json"), "utf8")).devDependencies.bun }, null, 2) + "\n");
console.log(output);
}
+195
View File
@@ -0,0 +1,195 @@
#!/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; }
}
+48
View File
@@ -0,0 +1,48 @@
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync, lstatSync } from "node:fs";
import { dirname, join } from "node:path";
import { parse, stringify } from "yaml";
export const serviceRoles = Object.freeze({ core: "core", frontend: "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", qdrant: "qdrant", embedding: "embedding", "embedding-model-init": "embedding" });
export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
/** Only distribution assets enter the bundle: never a checkout, environment file or workspace. */
export function prepareBundle({ source, destination, platform, version, revision, images }) {
const compose = parse(readFileSync(join(source, "compose.yaml"), "utf8"));
if (Object.keys(compose.services).sort().join() !== Object.keys(serviceRoles).sort().join()) throw new Error("Release service contract changed; review packaging before publishing.");
for (const [name, role] of Object.entries(serviceRoles)) {
if (!/^docker\.io\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/.test(images[role] ?? "")) throw new Error("Release requires immutable Docker Hub image references.");
delete compose.services[name].build;
delete compose.services[name].pull_policy;
compose.services[name].image = images[role];
compose.services[name].platform = platform;
}
const files = {};
const write = (name, data) => {
mkdirSync(dirname(join(destination, name)), { recursive: true });
writeFileSync(join(destination, name), data);
files[name] = sha256(data);
};
write("compose.yaml", stringify(compose));
for (const name of ["deploy/compose.local.yaml", "deploy/compose.git-https.yaml", "deploy/compose.git-ssh.yaml", "docker/catalog-db-init.sql", "docker/embedding-model-init.sh"]) {
const path = join(source, name);
if (!lstatSync(path).isFile()) throw new Error("Release asset must be a regular tracked file.");
write(name, readFileSync(path));
}
// Any future source bind mount must be deliberately added to the asset allowlist.
for (const service of Object.values(compose.services)) {
for (const volume of service.volumes ?? []) {
const sourcePath = typeof volume === "string" ? volume.split(":")[0] : volume.type === "bind" ? volume.source : undefined;
if (sourcePath?.startsWith(".") && !files[sourcePath.replace(/^\.\//, "")]) throw new Error("Source bind mount is missing from the release bundle.");
}
}
const manifest = { schema_version: 1, version, revision, validator_protocol: 1,
requirements: { cpus: 2, memory_bytes: 4 * 2 ** 30, disk_bytes: 10 * 2 ** 30 },
components: ["pi", "catalog-migrations", "workspace-maintenance"],
images: Object.fromEntries(Object.entries(images).map(([role, reference]) => [role, { [platform]: reference }])),
files, compose: ["compose.yaml", "deploy/compose.local.yaml"] };
writeFileSync(join(destination, "release-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
writeFileSync(join(destination, "README.md"), `# ThothII ${version} — ${platform}\n\nSource / Sorgente: ${revision}\n\nThis prerelease provides images and document/preflight tools. Non-interactive execution and real-host acceptance are separate follow-up tickets; this is not a certified complete installation.\nQuesta prerelease fornisce immagini e strumenti di preparazione/preflight. Esecuzione non interattiva e collaudi reali sono incrementi successivi: non è ancora un'installazione completa certificata.\n\nUse bin/tht and its sibling bin/tht-workspace-documents together; no Node, Python, Bun or application checkout is required on the consumer host.\nWindows: use the Linux amd64 bundle inside Ubuntu WSL2, not a native Windows shell.\n\n1. bin/tht workspace prepare --directory NEW_WORKSPACE --id practice --name Practice\n2. bin/tht workspace validate --directory WORKSPACE\n3. bin/tht installation prepare --directory NEW_PRIVATE_INSTALLATION\n4. bin/tht installation preflight --directory INSTALLATION --release ABSOLUTE_RELEASE_DIR/release-manifest.json\n5. Complete the commented documents, generate technical credentials explicitly, then run installation validate and installation plan with --installation ABSOLUTE_INSTALLATION_FILE.\n\nImages are pinned by digest; runtime credentials and user workspaces are never bundled.\nConsult the accompanying IT/EN guides for prepared documents and mandatory runtime checks.\n`);
for (const [name, target] of [["standalone-manual-it.md", "GUIDE-IT.md"], ["standalone-manual-en.md", "GUIDE-EN.md"], ["installation-preflight.md", "PREFLIGHT.md"]]) writeFileSync(join(destination, target), readFileSync(join(source, "docs/install", name)));
return manifest;
}
+35
View File
@@ -0,0 +1,35 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve, join } from "node:path";
import { parse } from "yaml";
import { prepareBundle } from "./release-bundle.mjs";
test("consumer bundle pins every service and carries all source bind resources", () => {
const output = mkdtempSync(join(tmpdir(), "thoth-release-test-"));
const images = Object.fromEntries(["core", "frontend", "catalog", "qdrant", "embedding"].map((role) => [role, `docker.io/tylconsulting/${role}@sha256:${"a".repeat(64)}`]));
try {
prepareBundle({ source: resolve(import.meta.dirname, "../.."), destination: output, platform: "linux/amd64", version: "0.1.0-install-preview.1", revision: "b".repeat(40), images });
const manifest = JSON.parse(readFileSync(join(output, "release-manifest.json")));
const compose = parse(readFileSync(join(output, "compose.yaml"), "utf8"));
assert.equal(compose.services.core.image, images.core);
assert.equal(compose.services["catalog-migrate"].image, images.core);
assert.equal(compose.services["workspace-maintenance"].image, images.core);
assert.equal(compose.services["embedding-model-init"].image, images.embedding);
for (const service of Object.values(compose.services)) {
assert.equal(service.build, undefined);
assert.equal(service.pull_policy, undefined);
assert.equal(service.platform, "linux/amd64");
for (const volume of service.volumes ?? []) {
if (typeof volume === "string" && volume.startsWith("./")) assert.ok(existsSync(join(output, volume.split(":")[0])));
}
}
assert.equal(manifest.validator_protocol, 1);
assert.deepEqual(manifest.compose, ["compose.yaml", "deploy/compose.local.yaml"]);
assert.ok(manifest.files["docker/catalog-db-init.sql"]);
assert.ok(manifest.files["docker/embedding-model-init.sh"]);
assert.ok(!existsSync(join(output, "backend")));
assert.ok(!existsSync(join(output, "harness")));
} finally { rmSync(output, { recursive: true, force: true }); }
});
+69
View File
@@ -0,0 +1,69 @@
import { readFileSync } from "node:fs";
import { boundedFetch, readLimited } from "./release-registry.mjs";
import { sha256 } from "./release-bundle.mjs";
export async function giteaHosting({ run, repository, identity, body }) {
const remote = new URL(repository);
const credential = await run("git", ["credential", "fill"], { input: `protocol=https\nhost=${remote.host}\n\n`, quiet: true, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
const fields = Object.fromEntries(credential.trim().split("\n").map((line) => { const at = line.indexOf("="); return [line.slice(0, at), line.slice(at + 1)]; }));
if (!fields.username || !fields.password) throw new Error("Gitea publishing credentials are unavailable in the Git credential store.");
const authorization = `Basic ${Buffer.from(`${fields.username}:${fields.password}`).toString("base64")}`;
const api = `${remote.origin}/api/v1/repos${remote.pathname.replace(/\.git$/, "")}`;
const marker = `<!-- thothii-release:${JSON.stringify(identity)} -->`;
const tag = `installation-v${identity.version}`;
async function request(path, options = {}, allowMissing = false) {
const response = await boundedFetch(api + path, { ...options, headers: { Authorization: authorization, ...options.headers } }, 120_000);
if (allowMissing && response.status === 404) return null;
if (!response.ok) throw new Error(`Gitea release operation failed (HTTP ${response.status}).`);
const bytes = await readLimited(response, 4 * 2 ** 20);
try { return JSON.parse(bytes.toString()); } catch { throw new Error("Gitea returned invalid release metadata."); }
}
const json = (method, value) => ({ method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(value) });
async function verifyTag(required = false) {
const existing = await request(`/tags/${encodeURIComponent(tag)}`, {}, true);
if ((!existing && required) || (existing && existing.commit?.sha !== identity.revision)) throw new Error("Release Git tag does not match the requested source revision.");
}
async function assets(release) { return request(`/releases/${release.id}/assets`); }
async function verifyAsset(asset, expected, publicRead) {
const url = new URL(asset.browser_download_url);
if (url.origin !== remote.origin) throw new Error("Unexpected release asset origin.");
const response = await boundedFetch(url, { headers: publicRead ? {} : { Authorization: authorization } }, 120_000);
if (!response.ok || sha256(await readLimited(response, expected.bytes + 1)) !== expected.sha256) throw new Error("Published release asset does not match its verified checksum.");
}
return {
async open() {
const info = await request("");
if (info.private || !info.permissions?.push) throw new Error("Release hosting must be a public Gitea repository with publication rights.");
await verifyTag();
const existing = await request(`/releases/tags/${encodeURIComponent(tag)}`, {}, true);
if (existing) {
if (!existing.body?.includes(marker)) throw new Error("Release version already belongs to another source or publication identity; it will not be overwritten.");
return existing;
}
return request("/releases", json("POST", { tag_name: tag, target_commitish: identity.revision, name: `ThothII ${identity.version}`, body: `${body}\n\n${marker}`, draft: true, prerelease: true }));
},
async upload(release, asset) {
const matching = (await assets(release)).filter((item) => item.name === asset.name);
if (matching.length > 1) throw new Error("Ambiguous release assets; no published files were replaced.");
if (matching.length === 1) { await verifyAsset(matching[0], asset, false); return; }
const data = readFileSync(asset.path);
if (sha256(data) !== asset.sha256) throw new Error("Local release asset changed before upload.");
const form = new FormData(); form.append("attachment", new Blob([data]), asset.name);
await request(`/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`, { method: "POST", body: form });
},
async verify(release, expected, publicRead) {
await verifyTag(publicRead);
const uploaded = await assets(release);
if (uploaded.length !== expected.length) throw new Error("Release asset set is incomplete or contains unexpected files.");
for (const asset of expected) {
const matching = uploaded.filter((item) => item.name === asset.name);
if (matching.length !== 1) throw new Error("Release asset missing or ambiguous.");
await verifyAsset(matching[0], asset, publicRead);
}
},
async publish(release) {
await verifyTag();
return request(`/releases/${release.id}`, json("PATCH", { draft: false }));
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { giteaHosting } from './release-hosting.mjs';
test('release hosting refuses a pre-existing Git tag on another commit', async () => {
const fetch = globalThis.fetch;
const revision = 'a'.repeat(40);
let writes = 0;
globalThis.fetch = async (url, options) => {
if (options.method) { writes++; return Response.json({ id: 1, draft: true }); }
if (String(url).includes('/releases/tags/')) return new Response('', { status: 404 });
if (String(url).includes('/tags/installation-v')) return Response.json({ commit: { sha: 'b'.repeat(40) } });
return Response.json({ private: false, permissions: { push: true } });
};
try {
const hosting = await giteaHosting({ run: async () => 'username=test\npassword=test\n', repository: 'https://example.test/owner/repo', identity: { revision, version: '1.0.0' }, body: '' });
await assert.rejects(hosting.open(), /tag.*revision/i);
assert.equal(writes, 0);
} finally { globalThis.fetch = fetch; }
});
test('matching Git tag is accepted and verified again before publishing', async () => {
const fetch = globalThis.fetch;
const identity = { revision: 'a'.repeat(40), version: '1.0.0' };
let sha = identity.revision;
let writes = 0;
globalThis.fetch = async (url, options) => {
if (options.method) { writes++; return Response.json({ id: 1, draft: false }); }
if (String(url).includes('/releases/tags/')) return Response.json({ id: 1, draft: true, body: `<!-- thothii-release:${JSON.stringify(identity)} -->` });
if (String(url).includes('/tags/')) return Response.json({ commit: { sha } });
if (String(url).endsWith('/assets')) return Response.json([]);
return Response.json({ private: false, permissions: { push: true } });
};
try {
const hosting = await giteaHosting({ run: async () => 'username=test\npassword=test\n', repository: 'https://example.test/owner/repo', identity, body: '' });
const release = await hosting.open();
await hosting.verify(release, [], false);
sha = 'b'.repeat(40);
await assert.rejects(hosting.verify(release, [], false), /tag.*revision/i);
await assert.rejects(hosting.publish(release), /tag.*revision/i);
assert.equal(writes, 0);
} finally { globalThis.fetch = fetch; }
});
+18
View File
@@ -0,0 +1,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { commandRunner } from './publish-installation.mjs';
test('publisher timeout terminates descendants that retain output pipes', async () => {
const logs = mkdtempSync(join(tmpdir(), 'release-process-test-'));
try {
const started = Date.now();
await assert.rejects(commandRunner(logs)(process.execPath, ['-e', `
require('child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 2500)'], {stdio: 'inherit'});
setTimeout(() => {}, 2500);
`], { timeout: 250, quiet: true }));
assert.ok(Date.now() - started < 1800, 'timeout must not wait for the descendant to exit naturally');
} finally { rmSync(logs, { recursive: true, force: true }); }
});
+14
View File
@@ -0,0 +1,14 @@
/** Drafts are the publication boundary. A partial build/upload is never a consumer release. */
export async function publishVerifiedRelease({ hosting, prepare }) {
const release = await hosting.open();
const assets = await prepare();
if (!release.draft) {
await hosting.verify(release, assets, true);
return release;
}
for (const asset of assets) await hosting.upload(release, asset);
await hosting.verify(release, assets, false);
const published = await hosting.publish(release);
await hosting.verify(published, assets, true);
return published;
}
@@ -0,0 +1,34 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { publishVerifiedRelease } from "./release-publication.mjs";
test("an interrupted preparation stays draft and retry publishes only after every artifact verifies", async () => {
const events = [];
let failing = true;
const hosting = {
open: async () => ({ id: 7, draft: true }),
upload: async (_draft, asset) => events.push(`upload:${asset.name}`),
verify: async (_draft, assets, publicRead) => events.push(`verify:${publicRead}:${assets.length}`),
publish: async () => { events.push("publish"); return { html_url: "https://example.test/release" }; },
};
const prepare = async () => {
if (failing) throw new Error("frontend build unavailable");
return [{ name: "bundle.tar.gz" }, { name: "SHA256SUMS" }];
};
await assert.rejects(publishVerifiedRelease({ hosting, prepare }), /frontend/);
assert.deepEqual(events, []);
failing = false;
await publishVerifiedRelease({ hosting, prepare });
assert.deepEqual(events, ["upload:bundle.tar.gz", "upload:SHA256SUMS", "verify:false:2", "publish", "verify:true:2"]);
});
test("a published version is verified without replacing any asset", async () => {
const events = [];
await publishVerifiedRelease({ prepare: async () => [{ name: "bundle.tar.gz" }], hosting: {
open: async () => ({ id: 7, draft: false }),
upload: async () => { throw new Error("overwrote a published version"); },
publish: async () => { throw new Error("republished a version"); },
verify: async (_release, _assets, publicRead) => events.push(publicRead),
} });
assert.deepEqual(events, [true]);
});
+86
View File
@@ -0,0 +1,86 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { sha256 } from "./release-bundle.mjs";
const accept = "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json";
export async function boundedFetch(url, options = {}, timeout = 30_000) {
try { return await fetch(url, { ...options, redirect: options.redirect ?? "error", signal: AbortSignal.timeout(timeout) }); }
catch { throw new Error("Release network request failed; retry with the same output directory."); }
}
export async function readLimited(response, maximum) {
const chunks = []; let size = 0;
for await (const chunk of response.body) { size += chunk.length; if (size > maximum) throw new Error("Release response exceeded its bound."); chunks.push(chunk); }
return Buffer.concat(chunks);
}
async function jsonResponse(response, description) {
if (!response.ok) throw new Error(`${description} refused (HTTP ${response.status}).`);
const bytes = await readLimited(response, 4 * 2 ** 20);
try { return { bytes, value: JSON.parse(bytes.toString()) }; }
catch { throw new Error("Release service returned invalid metadata."); }
}
export async function dockerCredentials(run) {
const config = JSON.parse(readFileSync(join(process.env.DOCKER_CONFIG || join(homedir(), ".docker"), "config.json"), "utf8"));
const server = "https://index.docker.io/v1/";
const helper = config.credHelpers?.[server] || config.credsStore;
if (!helper || !/^[A-Za-z0-9._-]+$/.test(helper)) throw new Error("Use docker login with an OS credential store before publishing.");
const auth = JSON.parse(await run(`docker-credential-${helper}`, ["get"], { input: server + "\n", quiet: true }));
if (!auth.Username || !auth.Secret) throw new Error("Docker Hub login is unavailable.");
return auth;
}
export function dockerHub(auth) {
async function token(repository, anonymous) {
const url = new URL("https://auth.docker.io/token");
url.searchParams.set("service", "registry.docker.io");
url.searchParams.set("scope", `repository:${repository}:pull`);
const response = await boundedFetch(url, { headers: anonymous ? {} : { Authorization: `Basic ${Buffer.from(`${auth.Username}:${auth.Secret}`).toString("base64")}` } });
return (await jsonResponse(response, "Registry authentication")).value.token;
}
async function registryJSON(repository, route, anonymous, allowMissing = false) {
const bearer = await token(repository, anonymous);
let response = await boundedFetch(`https://registry-1.docker.io/v2/${repository}/${route}`, { redirect: "manual", headers: { Accept: accept, Authorization: `Bearer ${bearer}` } });
if ([302, 307].includes(response.status) && route.startsWith("blobs/")) {
const target = new URL(response.headers.get("location"));
if (target.protocol !== "https:" || target.username || target.password) throw new Error("Invalid registry blob redirect.");
// Signed blob URLs are fetched without forwarding registry credentials.
response = await boundedFetch(target);
}
if (allowMissing && response.status === 404) return null;
const { bytes, value } = await jsonResponse(response, "Registry read");
const digest = `sha256:${sha256(bytes)}`;
const advertised = response.headers.get("docker-content-digest");
if (advertised && advertised !== digest) throw new Error("Registry content digest mismatch.");
return { value, digest };
}
return {
async ensurePublic(namespace, name) {
const response = await boundedFetch("https://hub.docker.com/v2/auth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier: auth.Username, secret: auth.Secret }) });
const bearer = (await jsonResponse(response, "Docker Hub authentication")).value.access_token;
const headers = { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" };
const path = `https://hub.docker.com/v2/namespaces/${namespace}/repositories`;
let existing = await boundedFetch(`${path}/${name}`, { headers });
if (existing.status === 404) {
existing = await boundedFetch(path, { method: "POST", headers, body: JSON.stringify({ namespace, name, registry: "docker.io", is_private: false, description: `ThothII ${name.endsWith("core") ? "core with embedded Pi" : "standalone frontend"}` }) });
}
const data = (await jsonResponse(existing, "Public repository preparation")).value;
if (data.is_private !== false) throw new Error("Selected Docker Hub repository is private; make this release repository public before retrying.");
},
async inspect(repository, reference, platform, { anonymous = false, allowMissing = false } = {}) {
let image = await registryJSON(repository, `manifests/${reference}`, anonymous, allowMissing);
if (!image) return null;
if (reference.startsWith("sha256:") && image.digest !== reference) throw new Error("Requested image digest does not match registry content.");
if (image.value.manifests) {
const match = image.value.manifests.find((entry) => `${entry.platform?.os}/${entry.platform?.architecture}` === platform);
if (!match || !/^sha256:[a-f0-9]{64}$/.test(match.digest)) throw new Error("Image does not contain the requested platform.");
image = await registryJSON(repository, `manifests/${match.digest}`, anonymous);
if (image.digest !== match.digest) throw new Error("Image index digest mismatch.");
}
if (reference.startsWith("sha256:") && !image.value.config) throw new Error("Image metadata is incomplete.");
const configDigest = image.value.config?.digest;
if (!/^sha256:[a-f0-9]{64}$/.test(configDigest ?? "")) throw new Error("Image configuration digest is invalid.");
const config = await registryJSON(repository, `blobs/${configDigest}`, anonymous);
if (config.digest !== configDigest || `${config.value.os}/${config.value.architecture}` !== platform) throw new Error("Image configuration or platform mismatch.");
return { reference: `docker.io/${repository}@${image.digest}`, labels: config.value.config?.Labels ?? {} };
},
};
}
+28
View File
@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { dockerHub } from "./release-registry.mjs";
import { sha256 } from "./release-bundle.mjs";
test("registry verifies pinned config/platform and does not forward auth to blob storage", async () => {
const original = globalThis.fetch;
const config = JSON.stringify({ os: "linux", architecture: "amd64", config: { Labels: { "org.opencontainers.image.revision": "b".repeat(40) } } });
const configDigest = "sha256:" + sha256(config);
const manifest = JSON.stringify({ schemaVersion: 2, config: { digest: configDigest } });
const imageDigest = "sha256:" + sha256(manifest);
const auth = [];
globalThis.fetch = async (target, options) => {
const url = new URL(target);
if (url.hostname === "auth.docker.io") return new Response(JSON.stringify({ token: "registry-token" }));
if (url.hostname === "blob.example.test") { auth.push(options.headers?.Authorization); return new Response(config); }
if (url.pathname.includes("/blobs/")) return new Response(null, { status: 307, headers: { location: "https://blob.example.test/config" } });
return new Response(manifest, { headers: { "docker-content-digest": imageDigest } });
};
try {
const registry = dockerHub({ Username: "publisher", Secret: "PRIVATE_TOKEN" });
const image = await registry.inspect("example/core", imageDigest, "linux/amd64", { anonymous: true });
assert.equal(image.reference, `docker.io/example/core@${imageDigest}`);
assert.deepEqual(auth, [undefined]);
await assert.rejects(registry.inspect("example/core", imageDigest, "linux/arm64"), /platform mismatch/);
await assert.rejects(registry.inspect("example/core", "sha256:" + "0".repeat(64), "linux/amd64"), /Requested image digest/);
} finally { globalThis.fetch = original; }
});
+94
View File
@@ -0,0 +1,94 @@
# Pubblicare immagini e pacchetto operatore / Publish images and operator bundle
## Italiano
Questo comando è riservato al manutentore. L'utente finale scarica il pacchetto e
le immagini già compilati. La prima prerelease riguarda **Linux amd64**, utilizzato
da Ubuntu WSL2 su Windows e successivamente da Omarchy; non certifica ancora il
percorso completo di installazione o i collaudi manuali.
Prerequisiti del manutentore: Git, Node 24/npm, Go indicato in `tools/tht/go.mod`,
Docker con Buildx e capacità di eseguire Linux amd64, `tar`. Bun viene installato
dal lock npm e serve soltanto per compilare il pacchetto. Il commit da pubblicare
deve essere già disponibile sul repository Gitea pubblico.
1. Eseguire `docker login` sul computer di pubblicazione con un account autorizzato
a creare repository pubblici e pubblicare immagini nel namespace scelto.
Usare un credential store Docker: il token non va passato sulla riga di comando,
scritto nel repository o copiato nel pacchetto.
2. Predisporre nel credential helper Git l'accesso al repository Gitea con diritto
di creare rilasci e allegati. Il comando riusa quelle credenziali senza stamparle.
3. Installare le dipendenze del produttore con `cd backend && npm ci`, poi eseguire:
```bash
node scripts/publish-installation.mjs \
--revision COMMIT_GIA_PUBBLICATO \
--version 0.1.0-install-preview.1 \
--namespace tylconsulting \
--platforms linux/amd64 \
--output /percorso/privato/rilascio-0.1.0-install-preview.1
```
La directory di output deve essere nuova o vuota e avere un genitore esistente.
Diventa privata e contiene stato di ripresa, log del produttore, archivi e checksum.
Non è una directory di installazione. Il produttore usa un worktree temporaneo al
commit richiesto, così modifiche locali, segreti e workspace non entrano nelle build.
Il comando prepara `tylconsulting/thothii-core` e `tylconsulting/thothii-frontend`
come repository pubblici, costruisce e pubblica le immagini versionate, risolve i
digest di tutte le immagini e crea gli archivi del comando nativo con Compose e
risorse di inizializzazione. Catalog migration e workspace maintenance usano lo
stesso digest core. PostgreSQL, Qdrant e Ollama restano immagini upstream.
Prima di rendere pubblico il rilascio Gitea, vengono verificati pull anonimi delle
immagini, smoke test senza rete di core/frontend, checksum e download degli allegati.
Una build o un upload incompleto lascia il rilascio **in bozza**. Per riprovare,
rieseguire lo stesso comando con gli stessi parametri e la stessa directory.
Immagini già presenti devono appartenere allo stesso commit/versione; allegati
esistenti devono avere lo stesso checksum. Il produttore non sostituisce versioni
pubblicate con contenuti diversi. Conservare la directory fino al completamento.
Il risultato pubblico comprende `thothii-VERSION-linux-amd64.tar.gz` e
`SHA256SUMS.txt`. L'archivio contiene `bin/tht`, il validatore affiancato,
`release-manifest.json`, Compose, SQL/script di inizializzazione e guide. Non
contiene credenziali, dati dei workspace o database di esempio. La verifica su
questa macchina di pubblicazione non sostituisce il successivo collaudo Windows.
## English
This is a maintainer command. Consumers download precompiled images and the native
operator bundle. The first prerelease targets **Linux amd64** for Ubuntu WSL2 and
later Omarchy; full installation and real-host acceptance remain separate work.
The maintainer needs Git, Node 24/npm, the Go toolchain from `tools/tht/go.mod`,
Docker Buildx with Linux amd64 execution support, and `tar`. The npm lock supplies
Bun for producer builds only. Push the selected source commit to the public Gitea
repository before publication. Use Docker's credential store for `docker login`
and Git's credential helper for Gitea release/attachment permissions. Never pass
tokens as command arguments or include them in a checkout or archive.
From `backend`, run `npm ci`, then the command above with an explicit revision,
version, namespace, platforms and a new private output directory. A temporary Git
worktree isolates the selected commit. The producer publishes core/frontend to
Docker Hub and retains PostgreSQL, Qdrant and Ollama upstream. All runtime and
maintenance services use resolved immutable platform digests.
The Gitea release stays a draft until images, anonymous pulls, network-isolated
smoke checks and uploaded bundle checksums pass. An interrupted run can be retried
with the same arguments and output directory. Existing images must match the
source/version, and existing attachments must match their checksum; published
versions are not overwritten. Producer logs and retry state stay local.
Download the matching `.tar.gz` and `SHA256SUMS.txt` from the public Gitea prerelease,
verify the archive checksum, then extract it. Keep the two executables together.
The bundle needs no application checkout, Node, Bun, Python or compiler on the
consumer host. It carries the manifest, Compose and initialization assets, but no
installation credentials, workspace data or example databases. Linux arm64 can be
selected explicitly for later release work; it does not imply macOS acceptance.
Developer regression checks:
```bash
cd backend
node --test scripts/release-*.test.mjs
```
+2
View File
@@ -57,6 +57,7 @@ exclude_docs: |
!/install/standalone-manual-it.md
!/install/standalone-manual-en.md
!/install/installation-preflight.md
!/install/publishing-images.md
!/install/shell-and-language.md
!/install/authentication-local.md
!/install/authentication-oidc.md
@@ -111,6 +112,7 @@ nav:
- Mac, Windows, Linux — Italiano: install/standalone-manual-it.md
- Mac, Windows, Linux — English: install/standalone-manual-en.md
- Preflight and release manifest: install/installation-preflight.md
- Publishing images and bundles: install/publishing-images.md
- Display mode and language: install/shell-and-language.md
- Local authentication: install/authentication-local.md
- OIDC authentication: install/authentication-oidc.md
+3
View File
@@ -77,6 +77,9 @@ complete parameter collection procedure, default `admin` account and local-auth
## Host preflight and installation plan (issue #45)
For the maintainer release producer and public native archives, see
[publishing images and bundles](../../docs/install/publishing-images.md).
`tht installation preflight --directory PATH [--release MANIFEST] [--json]` checks
the prepared directory and host without creating a stack. After completing the
documents, use `tht --installation ABS_PATH installation plan --workspaces PATH