// tools/curio-source/action.mjs import { appendFile, readFile } from "node:fs/promises"; import { createHash as createHash2 } from "node:crypto"; // tools/curio-source/catalog.mjs import { createHash } from "node:crypto"; // tools/curio-source/semver.mjs var SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; function parseSemver(value) { if (typeof value !== "string") throw new Error("Version must be a string"); const match = SEMVER.exec(value); if (!match) throw new Error(`Invalid semantic version: ${value}`); return { major: BigInt(match[1]), minor: BigInt(match[2]), patch: BigInt(match[3]), prerelease: match[4]?.split(".") ?? [] }; } function compareIdentifier(left, right) { const leftNumeric = /^\d+$/.test(left); const rightNumeric = /^\d+$/.test(right); if (leftNumeric && rightNumeric) return Number(BigInt(left) - BigInt(right)); if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; return left.localeCompare(right); } function compareSemver(left, right) { const a = parseSemver(left); const b = parseSemver(right); for (const key of ["major", "minor", "patch"]) { if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1; } if (!a.prerelease.length || !b.prerelease.length) { return a.prerelease.length === b.prerelease.length ? 0 : a.prerelease.length ? -1 : 1; } for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index++) { if (a.prerelease[index] === void 0) return -1; if (b.prerelease[index] === void 0) return 1; const compared = compareIdentifier(a.prerelease[index], b.prerelease[index]); if (compared) return compared; } return 0; } function tagMatchesVersion(tag, version) { return tag === version || tag === `v${version}`; } // tools/curio-source/catalog.mjs var TARGETS = /* @__PURE__ */ new Set(["any", "aarch64-apple-darwin", "x86_64-apple-darwin"]); var ID = /^[a-z0-9][a-z0-9.-]*[a-z0-9]$/; var SHA256 = /^[a-f0-9]{64}$/; function fail(message) { throw new Error(message); } function exactKeys(value, allowed, label) { for (const key of Object.keys(value)) { if (!allowed.includes(key)) fail(`${label} contains unknown field ${key}`); } } function text(value, label, maximum = 1e3) { if (typeof value !== "string" || !value.trim() || value.length > maximum) { fail(`${label} must be a non-empty string of at most ${maximum} characters`); } return value; } function httpUrl(value, label, { repository = false } = {}) { text(value, label, 4096); let url; try { url = new URL(value); } catch { fail(`${label} must be an absolute HTTP(S) URL`); } if (!url || !["http:", "https:"].includes(url.protocol) || url.username || url.password) { fail(`${label} must be an HTTP(S) URL without embedded credentials`); } if (repository && (url.search || url.hash || url.pathname === "/")) { fail(`${label} must identify a repository without a query or fragment`); } return url; } function repositorySlug(repository) { const url = httpUrl(repository, "Curio repository", { repository: true }); const parts = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "").split("/"); if (parts.length !== 2 || parts.some((part) => !/^[A-Za-z0-9_.-]+$/.test(part))) { fail("Curio repository URL must end in owner/repository"); } return parts.join("/"); } function repositoryOwner(repository) { return repositorySlug(repository).split("/")[0]; } function releaseAssetUrl(value, repository, tag, label) { const url = httpUrl(value, label); const repositoryUrl = httpUrl(repository, "Curio repository"); const repositoryPath2 = repositoryUrl.pathname.replace(/\/+$/, "").replace(/\.git$/, ""); const releasePath = `${repositoryPath2}/releases/download/${tag}/`; let pathname; try { pathname = decodeURIComponent(url.pathname); } catch { fail(`${label} has an invalid encoded path`); } if (url.origin !== repositoryUrl.origin || !pathname.startsWith(releasePath)) { fail(`${label} must belong to the bound repository Release`); } return url; } function date(value, label) { text(value, label, 100); if (!Number.isFinite(Date.parse(value))) fail(`${label} must be an RFC 3339 date-time`); } function same(left, right) { return JSON.stringify(left) === JSON.stringify(right); } function mutablePrerelease(version) { return parseSemver(version).prerelease.length > 0; } function normalizedName(value) { return value.normalize("NFKC").toLocaleLowerCase().replace(/[\s._-]+/g, ""); } function validateCatalog(index) { if (!index || typeof index !== "object" || Array.isArray(index)) fail("Source index must be an object"); exactKeys(index, ["schemaVersion", "id", "name", "icon", "generatedAt", "curios"], "Source index"); if (index.schemaVersion !== 2) fail("The public catalog requires Source Index schemaVersion 2"); text(index.id, "Source ID", 100); text(index.name, "Source name", 100); if (index.icon !== void 0) httpUrl(index.icon, "Source icon"); date(index.generatedAt, "generatedAt"); if (!Array.isArray(index.curios)) fail("curios must be an array"); const ids = /* @__PURE__ */ new Set(); const names = /* @__PURE__ */ new Map(); let previousId = ""; for (const curio of index.curios) { if (!curio || typeof curio !== "object" || Array.isArray(curio)) fail("Each Curio must be an object"); exactKeys(curio, ["id", "name", "description", "repository", "icon", "versions"], "Curio"); if (typeof curio.id !== "string" || curio.id.length > 160 || !ID.test(curio.id)) fail(`Invalid Curio ID: ${curio.id}`); if (ids.has(curio.id)) fail(`Duplicate Curio ID: ${curio.id}`); if (previousId && previousId.localeCompare(curio.id) >= 0) fail("Curios must be sorted by ID"); previousId = curio.id; ids.add(curio.id); text(curio.name, `${curio.id} name`, 100); if (curio.description !== void 0 && (typeof curio.description !== "string" || curio.description.length > 1e3)) fail(`${curio.id} has an invalid description`); repositorySlug(curio.repository); if (curio.icon !== void 0) httpUrl(curio.icon, `${curio.id} icon`); const name = normalizedName(curio.name); if (names.has(name)) fail(`${curio.name} conflicts with ${names.get(name)}`); names.set(name, curio.name); if (!Array.isArray(curio.versions) || !curio.versions.length) fail(`${curio.id} has no versions`); const versions = /* @__PURE__ */ new Set(); let previousVersion; for (const version of curio.versions) { if (!version || typeof version !== "object" || Array.isArray(version)) fail(`${curio.id} has an invalid version`); exactKeys(version, ["version", "engine", "releasedAt", "descriptor", "artifacts"], `${curio.id} version`); parseSemver(version.version); if (versions.has(version.version)) fail(`${curio.id} has duplicate version ${version.version}`); if (previousVersion && compareSemver(previousVersion, version.version) <= 0) fail(`${curio.id} versions must be newest first`); previousVersion = version.version; versions.add(version.version); if (!version.engine || typeof version.engine !== "object" || Array.isArray(version.engine)) fail(`${curio.id}@${version.version} has no Trove compatibility range`); exactKeys(version.engine, ["trove"], `${curio.id}@${version.version} engine`); if (typeof version.engine.trove !== "string" || !version.engine.trove.trim()) fail(`${curio.id}@${version.version} has no Trove compatibility range`); date(version.releasedAt, `${curio.id}@${version.version} releasedAt`); httpUrl(version.descriptor, `${curio.id}@${version.version} descriptor`); if (!Array.isArray(version.artifacts) || !version.artifacts.length || version.artifacts.length > 8) fail(`${curio.id}@${version.version} has invalid artifacts`); const targets = /* @__PURE__ */ new Set(); let previousTarget = ""; for (const artifact of version.artifacts) { if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) fail(`${curio.id}@${version.version} has an invalid artifact`); exactKeys(artifact, ["target", "url", "sha256", "size"], `${curio.id}@${version.version} artifact`); if (!TARGETS.has(artifact.target) || targets.has(artifact.target)) fail(`${curio.id}@${version.version} has an invalid or duplicate target`); if (previousTarget && previousTarget.localeCompare(artifact.target) >= 0) fail(`${curio.id}@${version.version} artifacts must be sorted by target`); previousTarget = artifact.target; targets.add(artifact.target); httpUrl(artifact.url, `${curio.id}@${version.version}/${artifact.target} URL`); if (!SHA256.test(artifact.sha256)) fail(`${curio.id}@${version.version}/${artifact.target} has an invalid SHA-256`); if (!Number.isSafeInteger(artifact.size) || artifact.size < 1 || artifact.size > 512 * 1024 * 1024) fail(`${curio.id}@${version.version}/${artifact.target} has an invalid size`); } } } return index; } function validateDescriptor(descriptor, registry) { if (!descriptor || descriptor.schemaVersion !== 2) fail("Release descriptor must use schemaVersion 2"); exactKeys(descriptor, ["schemaVersion", "repository", "tag", "commit", "registries", "plugins"], "Release descriptor"); text(descriptor.repository, "Release repository", 300); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(descriptor.repository)) fail("Release repository must be owner/name"); text(descriptor.tag, "Release tag", 200); if (!/^[a-f0-9]{40,64}$/.test(descriptor.commit)) fail("Release descriptor has an invalid commit"); if (!Array.isArray(descriptor.registries) || descriptor.registries.some((item) => typeof item !== "string") || !descriptor.registries.includes(registry)) fail(`Release does not opt in to ${registry}`); if (!Array.isArray(descriptor.plugins) || !descriptor.plugins.length) fail("Release descriptor has no Curios"); const plugins = /* @__PURE__ */ new Set(); for (const plugin of descriptor.plugins) { if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) fail("Release descriptor has an invalid Curio"); exactKeys(plugin, ["id", "name", "description", "icon", "version", "engine", "artifacts"], "Release Curio"); if (!ID.test(plugin.id)) fail(`Release descriptor has an invalid Curio ID: ${plugin.id}`); parseSemver(plugin.version); if (plugins.has(`${plugin.id}@${plugin.version}`)) fail(`Release descriptor repeats ${plugin.id}@${plugin.version}`); plugins.add(`${plugin.id}@${plugin.version}`); if (!tagMatchesVersion(descriptor.tag, plugin.version)) fail(`${plugin.id} version does not match release tag ${descriptor.tag}`); text(plugin.name, `${plugin.id} name`, 100); if (plugin.description !== void 0 && (typeof plugin.description !== "string" || plugin.description.length > 1e3)) fail(`${plugin.id} has an invalid description`); if (plugin.icon !== void 0) httpUrl(plugin.icon, `${plugin.id} icon`); if (!plugin.engine || typeof plugin.engine !== "object" || Array.isArray(plugin.engine)) fail(`${plugin.id} has no Trove compatibility range`); exactKeys(plugin.engine, ["trove"], `${plugin.id} engine`); if (typeof plugin.engine.trove !== "string" || !plugin.engine.trove.trim()) fail(`${plugin.id} has no Trove compatibility range`); if (!Array.isArray(plugin.artifacts) || !plugin.artifacts.length) fail(`${plugin.id} has no release artifacts`); const targets = /* @__PURE__ */ new Set(); for (const artifact of plugin.artifacts) { if (!artifact || typeof artifact !== "object" || Array.isArray(artifact)) fail(`${plugin.id} has an invalid release artifact`); exactKeys(artifact, ["target", "asset", "sha256", "size"], `${plugin.id} release artifact`); if (!TARGETS.has(artifact.target) || targets.has(artifact.target)) fail(`${plugin.id} has an invalid or duplicate target`); targets.add(artifact.target); if (!/^[A-Za-z0-9._+-]+\.curio$/.test(artifact.asset)) fail(`${plugin.id} has an invalid asset name`); if (!SHA256.test(artifact.sha256) || !Number.isSafeInteger(artifact.size) || artifact.size < 1 || artifact.size > 512 * 1024 * 1024) fail(`${plugin.id}/${artifact.target} has invalid artifact metadata`); } } return descriptor; } function applyDescriptor(index, descriptor, release, registry) { validateCatalog(index); validateDescriptor(descriptor, registry); if (repositorySlug(release.repositoryUrl) !== descriptor.repository) fail("Release repository differs from its descriptor"); const next = structuredClone(index); for (const plugin of descriptor.plugins) { let curio = next.curios.find((item) => item.id === plugin.id); if (curio && curio.repository !== release.repositoryUrl) fail(`${plugin.id} is owned by ${curio.repository}`); if (!curio) { curio = { id: plugin.id, name: plugin.name, description: plugin.description ?? "", repository: release.repositoryUrl, ...plugin.icon ? { icon: plugin.icon } : {}, versions: [] }; next.curios.push(curio); } const artifacts = plugin.artifacts.map((artifact) => { const url = release.assets.get(artifact.asset); if (!url) fail(`Release is missing ${artifact.asset}`); return { target: artifact.target, url, sha256: artifact.sha256, size: artifact.size }; }).sort((a, b) => a.target.localeCompare(b.target)); const candidate = { version: plugin.version, engine: structuredClone(plugin.engine), releasedAt: release.publishedAt, descriptor: release.descriptorUrl, artifacts }; const existing = curio.versions.find((item) => item.version === plugin.version); if (existing) { if (same(existing, candidate)) continue; if (!mutablePrerelease(plugin.version)) fail(`${plugin.id}@${plugin.version} is immutable; publish a new version`); Object.assign(existing, candidate); curio.name = plugin.name; curio.description = plugin.description ?? ""; if (plugin.icon) curio.icon = plugin.icon; else delete curio.icon; continue; } const newest = curio.versions.every((item) => compareSemver(plugin.version, item.version) > 0); curio.versions.push(candidate); curio.versions.sort((a, b) => compareSemver(b.version, a.version)); if (newest) { curio.name = plugin.name; curio.description = plugin.description ?? ""; if (plugin.icon) curio.icon = plugin.icon; } } next.curios.sort((a, b) => a.id.localeCompare(b.id)); if (Date.parse(release.publishedAt) > Date.parse(next.generatedAt)) next.generatedAt = release.publishedAt; return validateCatalog(next); } function planCatalogChange(base, head) { validateCatalog(base); validateCatalog(head); if (base.id !== head.id || base.name !== head.name || (base.icon ?? null) !== (head.icon ?? null)) fail("A catalog PR cannot change source identity"); if (Date.parse(head.generatedAt) < Date.parse(base.generatedAt)) fail("generatedAt cannot move backwards"); const additions = []; const headById = new Map(head.curios.map((curio) => [curio.id, curio])); for (const oldCurio of base.curios) { const curio = headById.get(oldCurio.id); if (!curio) fail(`A catalog PR cannot remove ${oldCurio.id}`); if (curio.repository !== oldCurio.repository) fail(`${curio.id} repository ownership cannot change in a release PR`); const versions = new Map(curio.versions.map((version) => [version.version, version])); const replaced = []; for (const oldVersion of oldCurio.versions) { const version = versions.get(oldVersion.version); if (!version) fail(`${curio.id}@${oldVersion.version} cannot be removed`); if (!same(version, oldVersion)) { if (!mutablePrerelease(oldVersion.version)) fail(`${curio.id}@${oldVersion.version} is immutable`); replaced.push(version); } } const added = curio.versions.filter((version) => !oldCurio.versions.some((item) => item.version === version.version)); const releases = [...added, ...replaced]; const metadataChanged = !same( { name: oldCurio.name, description: oldCurio.description ?? "", icon: oldCurio.icon ?? null }, { name: curio.name, description: curio.description ?? "", icon: curio.icon ?? null } ); if (metadataChanged && !releases.length) fail(`${curio.id} metadata changes require a release`); additions.push(...releases.map((version) => ({ curio, version, isNewCurio: false, metadataChanged }))); } for (const curio of head.curios) { if (!base.curios.some((item) => item.id === curio.id)) { additions.push(...curio.versions.map((version) => ({ curio, version, isNewCurio: true, metadataChanged: true }))); } } if (!additions.length && !same(base, head)) fail("Catalog data changed without adding a release"); return { additions, newCurios: [...new Set(additions.filter((item) => item.isNewCurio).map((item) => item.curio.id))] }; } async function verifyReleaseAddition(addition, { registry, fetchJson: fetchJson2, fetchBytes: fetchBytes2 }) { const descriptor = validateDescriptor(await fetchJson2(addition.version.descriptor, 1024 * 1024), registry); if (repositorySlug(addition.curio.repository) !== descriptor.repository) fail(`${addition.curio.id} descriptor belongs to another repository`); const descriptorUrl = releaseAssetUrl( addition.version.descriptor, addition.curio.repository, descriptor.tag, "Release descriptor URL" ); if (!descriptorUrl.pathname.endsWith("/trove-release.json")) fail(`${addition.curio.id} descriptor is not hosted by its repository`); const plugin = descriptor.plugins.find((item) => item.id === addition.curio.id && item.version === addition.version.version); if (!plugin) fail(`${addition.curio.id}@${addition.version.version} is missing from its release descriptor`); if (plugin.name !== addition.curio.name || (plugin.description ?? "") !== (addition.curio.description ?? "") || (plugin.icon ?? null) !== (addition.curio.icon ?? null)) fail(`${addition.curio.id} metadata differs from its release descriptor`); if (!same(plugin.engine, addition.version.engine)) fail(`${addition.curio.id}@${plugin.version} compatibility differs from its release descriptor`); if (plugin.artifacts.length !== addition.version.artifacts.length) fail(`${addition.curio.id}@${plugin.version} artifact count differs from its release descriptor`); for (const artifact of addition.version.artifacts) { const expected = plugin.artifacts.find((item) => item.target === artifact.target); if (!expected || expected.sha256 !== artifact.sha256 || expected.size !== artifact.size) fail(`${addition.curio.id}@${plugin.version}/${artifact.target} differs from its release descriptor`); const artifactUrl = releaseAssetUrl( artifact.url, addition.curio.repository, descriptor.tag, `${addition.curio.id}@${plugin.version}/${artifact.target} URL` ); const name = decodeURIComponent(artifactUrl.pathname.split("/").at(-1) ?? ""); if (name !== expected.asset) fail(`${addition.curio.id}@${plugin.version}/${artifact.target} URL names the wrong asset`); const bytes = await fetchBytes2(artifact.url, artifact.size); if (bytes.length !== artifact.size || createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) fail(`${addition.curio.id}@${plugin.version}/${artifact.target} checksum mismatch`); } return descriptor; } function canonicalCatalog(index) { validateCatalog(index); return `${JSON.stringify(index, null, 2)} `; } // tools/curio-source/network.mjs import dns from "node:dns/promises"; import net from "node:net"; function privateIpv4(address) { const parts = address.split(".").map(Number); const [a, b] = parts; return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a >= 224; } function privateAddress(address) { if (net.isIPv4(address)) return privateIpv4(address); const value = address.toLowerCase(); return value === "::" || value === "::1" || value.startsWith("fc") || value.startsWith("fd") || /^fe[89ab]/.test(value) || value.startsWith("::ffff:") && privateIpv4(value.slice(7)); } async function publicUrl(value) { const url = new URL(value); if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error("Remote URL must use HTTP(S) without credentials"); const addresses = await dns.lookup(url.hostname, { all: true, verbatim: true }); if (!addresses.length || addresses.some(({ address }) => privateAddress(address))) throw new Error(`Remote URL does not resolve exclusively to public addresses: ${url.hostname}`); return url; } async function response(value) { let url = await publicUrl(value); for (let redirects = 0; redirects <= 5; redirects++) { const result = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(12e4) }); if ([301, 302, 303, 307, 308].includes(result.status)) { const location = result.headers.get("location"); await result.body?.cancel(); if (!location) throw new Error("Remote redirect has no location"); url = await publicUrl(new URL(location, url).href); continue; } if (!result.ok) throw new Error(`Remote download returned HTTP ${result.status}`); return result; } throw new Error("Remote download has too many redirects"); } async function fetchBytes(url, limit) { const result = await response(url); const chunks = []; let size = 0; for await (const chunk of result.body) { size += chunk.length; if (size > limit) throw new Error("Remote download exceeds its size limit"); chunks.push(Buffer.from(chunk)); } return Buffer.concat(chunks); } async function fetchJson(url, limit) { const bytes = await fetchBytes(url, limit); try { return JSON.parse(bytes); } catch { throw new Error("Remote release descriptor is not valid JSON"); } } // tools/curio-source/gitea.mjs function repositoryPath(repository) { if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error(`Invalid repository: ${repository}`); return repository.split("/").map(encodeURIComponent).join("/"); } var Gitea = class { constructor({ server = process.env.GITEA_SERVER_URL, token = process.env.GITEA_TOKEN || process.env.GITHUB_TOKEN } = {}) { const origin = new URL(server || "https://git.render.ink"); if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password || origin.pathname !== "/") throw new Error("Gitea server must be an HTTP(S) origin without credentials"); this.server = origin.origin; this.token = token; } async api(route, { method = "GET", body, missing = false } = {}) { const response2 = await fetch(`${this.server}/api/v1${route}`, { method, redirect: "error", signal: AbortSignal.timeout(12e4), headers: { ...this.token ? { Authorization: `token ${this.token}` } : {}, ...body && !(body instanceof FormData) ? { "Content-Type": "application/json" } : {} }, body: body instanceof FormData ? body : body === void 0 ? void 0 : JSON.stringify(body) }); if (missing && response2.status === 404) return null; if (!response2.ok) { const error = new Error(`Gitea ${method} ${route.split("?")[0]} returned HTTP ${response2.status}`); error.status = response2.status; throw error; } if (response2.status === 204 || response2.headers.get("content-length") === "0") return null; return response2.json(); } async download(url, limit = 512 * 1024 * 1024) { let current = new URL(url); for (let redirects = 0; redirects <= 5; redirects++) { if (!["http:", "https:"].includes(current.protocol) || current.username || current.password) throw new Error("Invalid release download URL"); const response2 = await fetch(current, { redirect: "manual", signal: AbortSignal.timeout(12e4), headers: current.origin === this.server && this.token ? { Authorization: `token ${this.token}` } : {} }); if ([301, 302, 303, 307, 308].includes(response2.status)) { const location = response2.headers.get("location"); await response2.body?.cancel(); if (!location) throw new Error("Release redirect has no location"); current = new URL(location, current); continue; } if (!response2.ok) throw new Error(`Release download returned HTTP ${response2.status}`); const chunks = []; let size = 0; for await (const chunk of response2.body) { size += chunk.length; if (size > limit) throw new Error("Release download exceeds its size limit"); chunks.push(Buffer.from(chunk)); } return Buffer.concat(chunks); } throw new Error("Release download has too many redirects"); } async json(url, limit = 1024 * 1024) { try { return JSON.parse(await this.download(url, limit)); } catch (error) { if (error instanceof SyntaxError) throw new Error("Release descriptor is not valid JSON"); throw error; } } }; // tools/curio-source/action.mjs var input = (name, fallback) => process.env[`INPUT_${name.toUpperCase()}`] || fallback; async function output(name, value) { const file = process.env.GITHUB_OUTPUT || process.env.GITEA_OUTPUT; if (file) await appendFile(file, `${name}=${String(value).replaceAll("\n", "%0A")} `); } function event() { const file = process.env.GITEA_EVENT_PATH || process.env.GITHUB_EVENT_PATH; if (!file) throw new Error("Action event payload is missing"); return readFile(file, "utf8").then(JSON.parse); } function decodeContent(file) { if (!file?.content) throw new Error("Catalog index is missing"); return JSON.parse(Buffer.from(file.content, "base64").toString("utf8")); } function decodeBytes(file) { if (!file?.content || file.type !== "file") throw new Error("Catalog asset is missing"); return Buffer.from(file.content, "base64"); } function png(bytes) { return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); } function webp(bytes) { return bytes.length >= 12 && bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WEBP"; } async function validateChangedIcons(paths, catalog, { client, route, ref }) { const icons = paths.filter((path) => path.startsWith("source/icons/")); const referenced = new Set(catalog.curios.map((curio) => { if (!curio.icon) return null; try { const url = new URL(curio.icon); const marker = "/raw/branch/main/"; return url.pathname.includes(marker) ? url.pathname.split(marker)[1] : null; } catch { return null; } }).filter(Boolean)); for (const path of icons) { if (!/^source\/icons\/[a-z0-9][a-z0-9.-]*[a-z0-9]\.(png|webp)$/.test(path)) throw new Error(`Invalid catalog icon path: ${path}`); const encodedPath = path.split("/").map(encodeURIComponent).join("/"); const file = await client.api(`${route}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`); const bytes = decodeBytes(file); if (bytes.length < 1 || bytes.length > 256 * 1024) throw new Error(`${path} must be a non-empty image no larger than 256 KiB`); if (!png(bytes) && !webp(bytes)) throw new Error(`${path} must contain PNG or WebP data`); if (!referenced.has(path)) throw new Error(`${path} is not referenced by a Curio entry`); } } async function validatePullRequest() { const payload = await event(); const pull = payload.pull_request; if (!pull) throw new Error("catalog validation only runs for pull requests"); const catalogRepository = input("catalog", process.env.GITEA_REPOSITORY); const trustedOwner = input("trusted_owner", "trove-curios"); const client = new Gitea(); const route = `/repos/${repositoryPath(catalogRepository)}`; const headRepository = pull.head?.repo?.full_name || catalogRepository; const headRoute = `/repos/${repositoryPath(headRepository)}`; const base = decodeContent(await client.api(`${route}/contents/source/index.json?ref=${encodeURIComponent(pull.base.sha)}`)); const pullNumber = pull.number ?? pull.index; if (Number(pull.changed_files) > 100) { throw new Error("Catalog PRs may change at most 100 files"); } const files = await client.api(`${route}/pulls/${pullNumber}/files?limit=100&page=1`); if (files.length === 100) { const overflow = await client.api(`${route}/pulls/${pullNumber}/files?limit=100&page=2`); if (overflow.length) throw new Error("Catalog PRs may change at most 100 files"); } const paths = files.map((file) => file.filename); if (!paths.includes("source/index.json")) { console.log("No catalog data changed; catalog validation is not required for this PR."); await output("review_required", true); return; } const unexpected = paths.filter((path) => path !== "source/index.json" && !path.startsWith("source/icons/")); if (unexpected.length) throw new Error(`A catalog data PR cannot change other files: ${unexpected.join(", ")}`); const head = decodeContent(await client.api(`${headRoute}/contents/source/index.json?ref=${encodeURIComponent(pull.head.sha)}`)); const plan = planCatalogChange(base, head); if (plan.additions.length > 20) throw new Error("A catalog PR may add at most 20 releases"); const downloadBytes = plan.additions.reduce( (total, addition) => total + addition.version.artifacts.reduce((sum, artifact) => sum + artifact.size, 0), 0 ); if (downloadBytes > 1024 * 1024 * 1024) { throw new Error("A catalog PR may verify at most 1 GiB of release assets"); } await validateChangedIcons(paths, head, { client, route: headRoute, ref: pull.head.sha }); for (const addition of plan.additions) { await verifyReleaseAddition(addition, { registry: catalogRepository, fetchJson, fetchBytes }); } const reviewRequired = plan.newCurios.some((id) => { const curio = head.curios.find((item) => item.id === id); return repositoryOwner(curio.repository) !== trustedOwner || new URL(curio.repository).origin !== client.server; }); console.log(`Validated ${plan.additions.length} release(s).${reviewRequired ? " First-time third-party publication requires review." : " Provenance permits automatic merge."}`); await output("review_required", reviewRequired); await output("release_count", plan.additions.length); if (!reviewRequired && plan.additions.length) { await client.api(`${route}/pulls/${pullNumber}/merge`, { method: "POST", body: { do: "merge", merge_when_checks_succeed: true, delete_branch_after_merge: true } }); console.log("Scheduled merge after required checks succeed."); } } async function releaseCommit(client, repository, tag) { const refs = await client.api(`/repos/${repositoryPath(repository)}/git/refs/tags/${encodeURIComponent(tag)}`); let object = (Array.isArray(refs) ? refs.find((ref) => ref.ref === `refs/tags/${tag}`) : refs)?.object; for (let depth = 0; object?.type === "tag" && depth < 5; depth++) { object = (await client.api(`/repos/${repositoryPath(repository)}/git/tags/${object.sha}`)).object; } if (object?.type !== "commit") throw new Error("Release tag does not resolve to a commit"); return object.sha; } async function verifyPublishedRelease(client, repository, tag, registry) { const release = await client.api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`); if (release.draft) throw new Error("Draft releases cannot enter the catalog"); const descriptorAsset = release.assets.find((asset) => asset.name === "trove-release.json"); if (!descriptorAsset) throw new Error("Release has no trove-release.json attachment"); const descriptor = validateDescriptor(await client.json(descriptorAsset.browser_download_url), registry); if (descriptor.repository !== repository || descriptor.tag !== tag) throw new Error("Release identity differs from its descriptor"); if (descriptor.commit !== await releaseCommit(client, repository, tag)) throw new Error("Release tag differs from the descriptor commit"); const assets = /* @__PURE__ */ new Map(); for (const plugin of descriptor.plugins) { for (const artifact of plugin.artifacts) { const asset = release.assets.find((item) => item.name === artifact.asset); if (!asset || asset.size !== artifact.size) throw new Error(`Release is missing ${artifact.asset}`); const bytes = await client.download(asset.browser_download_url, artifact.size); if (bytes.length !== artifact.size || createHash2("sha256").update(bytes).digest("hex") !== artifact.sha256) throw new Error(`Release checksum mismatch: ${plugin.id}/${artifact.target}`); assets.set(artifact.asset, asset.browser_download_url); } } return { release, descriptor, descriptorAsset, assets }; } async function proposeRelease() { const repository = input("repository", process.env.GITEA_REPOSITORY); const tag = input("tag"); const catalogRepository = input("catalog", "trove/curios"); const trustedOwner = input("trusted_owner", "trove-curios"); if (!tag) throw new Error("Release tag is required"); if (repository.split("/")[0] !== trustedOwner) throw new Error(`${repository} is not an official Curio repository`); if (!process.env.CATALOG_TOKEN) throw new Error("Catalog write token is missing"); const sourceClient = new Gitea({ token: process.env.GITHUB_TOKEN }); const catalogClient = new Gitea({ token: process.env.CATALOG_TOKEN }); if (sourceClient.server !== catalogClient.server) throw new Error("Release and catalog must use the same Gitea server"); const published = await verifyPublishedRelease(sourceClient, repository, tag, catalogRepository); const route = `/repos/${repositoryPath(catalogRepository)}`; const file = await catalogClient.api(`${route}/contents/source/index.json?ref=main`); const base = decodeContent(file); const repositoryUrl = `${catalogClient.server}/${repository}`; const next = applyDescriptor(base, published.descriptor, { repositoryUrl, descriptorUrl: published.descriptorAsset.browser_download_url, publishedAt: published.release.published_at || published.release.created_at, assets: published.assets }, catalogRepository); const content = canonicalCatalog(next); const before = Buffer.from(file.content, "base64").toString("utf8"); if (content === before) { console.log("Release is already present in the catalog."); return; } const plugin = published.descriptor.plugins[0]; const branch = `release/${plugin.id}/${plugin.version}`; const open = await catalogClient.api(`${route}/pulls?state=open&limit=100`); let pull = open.find((item) => item.head?.ref === branch); const existingBranch = await catalogClient.api( `${route}/branches/${encodeURIComponent(branch)}`, { missing: true } ); const branchFile = existingBranch ? await catalogClient.api(`${route}/contents/source/index.json?ref=${encodeURIComponent(branch)}`) : null; const branchContent = branchFile ? Buffer.from(branchFile.content, "base64").toString("utf8") : null; if (branchContent !== content) { await catalogClient.api(`${route}/contents/source/index.json`, { method: "PUT", body: { branch: existingBranch ? branch : "main", ...existingBranch ? {} : { new_branch: branch }, sha: branchFile?.sha || file.sha, content: Buffer.from(content).toString("base64"), message: `release(${plugin.id}): ${plugin.version}` } }); } if (!pull) { pull = await catalogClient.api(`${route}/pulls`, { method: "POST", body: { base: "main", head: branch, title: `release(${plugin.id}): ${plugin.version}`, body: [ `Curio: \`${plugin.id}\``, `Version: \`${plugin.version}\``, `Repository: ${repositoryUrl}`, `Release: ${published.release.html_url}`, "", "This proposal was generated from the published release descriptor." ].join("\n") } }); } console.log(`Release proposal: ${pull.html_url}`); await output("pull_request", pull.html_url); } async function runAction() { const mode = input("mode"); if (mode === "validate") return validatePullRequest(); if (mode === "propose") return proposeRelease(); throw new Error(`Unknown Curio source action mode: ${mode}`); } // .gitea/actions/curio-source/main.mjs runAction().catch((error) => { console.error(error instanceof Error ? error.stack : error); process.exitCode = 1; });