70 lines
4.7 KiB
JavaScript
70 lines
4.7 KiB
JavaScript
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 }));
|
|
},
|
|
};
|
|
}
|