chore: publish clean Curio source

This commit is contained in:
2026-09-23 16:09:14 +08:00
commit 4ab217897e
12 changed files with 991 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
name: Curio source
description: Validate a Curio source PR or propose an official release
inputs:
mode:
description: validate or propose
required: true
repository:
description: Published Curio repository
required: false
tag:
description: Published release tag
required: false
catalog:
description: Catalog repository owner/name
required: false
default: trove/curios
trusted_owner:
description: Repository owner whose first releases may merge automatically
required: false
default: trove-curios
runs:
using: node20
main: main.mjs
+690
View File
@@ -0,0 +1,690 @@
// 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`);
const client = new Gitea();
const published = await verifyPublishedRelease(client, repository, tag, catalogRepository);
const route = `/repos/${repositoryPath(catalogRepository)}`;
const file = await client.api(`${route}/contents/source/index.json?ref=main`);
const base = decodeContent(file);
const repositoryUrl = `${client.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 client.api(`${route}/pulls?state=open&limit=100`);
let pull = open.find((item) => item.head?.ref === branch);
const existingBranch = await client.api(
`${route}/branches/${encodeURIComponent(branch)}`,
{ missing: true }
);
const branchFile = existingBranch ? await client.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 client.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 client.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;
});