chore: publish clean Curio source
This commit is contained in:
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user