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 = ``; 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 })); }, }; }