chore(catalog): initialize production plugin source

This commit is contained in:
2026-09-11 10:58:33 +08:00
commit 557ed8afd0
18 changed files with 596 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Source this file in a Linux job after checking out the catalog.
set -euo pipefail
export npm_config_registry="${NPM_REGISTRY:-https://registry.npmmirror.com}"
export COREPACK_NPM_REGISTRY="$npm_config_registry"
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
if ! command -v node >/dev/null || ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)'; then
case "$(uname -m)" in
x86_64) node_arch=x64; node_sha=d60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307 ;;
aarch64|arm64) node_arch=arm64; node_sha=fff4078c5def658577f92c88db7db3bc0072924bfb93fe52c1e744a54e94abb8 ;;
*) echo 'Unsupported Linux runner architecture' >&2; exit 1 ;;
esac
node_archive="node-v22.23.2-linux-${node_arch}.tar.xz"
node_cache="${RUNNER_TEMP:-/tmp}/trove-node-22.23.2-${node_arch}"
mkdir -p "$node_cache"
curl --fail --location --retry 3 --connect-timeout 15 --max-time 180 \
"${NODE_MIRROR:-https://npmmirror.com/mirrors/node}/v22.23.2/$node_archive" -o "$node_cache/$node_archive"
# Hashes are pinned from the official nodejs.org SHASUMS256.txt.
printf '%s %s\n' "$node_sha" "$node_cache/$node_archive" | sha256sum --check --status
tar -xJf "$node_cache/$node_archive" -C "$node_cache" --strip-components=1
export PATH="$node_cache/bin:$PATH"
fi
node --version
# The catalog only needs semver; avoid downloading pnpm/Corepack in each job.
npm install --ignore-scripts --no-audit --no-fund --package-lock=false
+87
View File
@@ -0,0 +1,87 @@
import { execFileSync } from 'node:child_process';
const origin = new URL(process.env.GITEA_SERVER_URL || 'https://git.render.ink');
if (origin.protocol !== 'https:' || origin.username || origin.password) throw new Error('Gitea requires an HTTPS origin without credentials');
export const server = origin.origin;
export const catalogRepository = process.env.CATALOG_REPOSITORY || 'trove/curios';
export function repositoryPath(repository) {
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repository)) throw new Error('Invalid repository name');
return repository.split('/').map(encodeURIComponent).join('/');
}
export async function api(route, { token = process.env.GITEA_TOKEN, method = 'GET', body, missing = false } = {}) {
const auth = authorization(token);
const response = await fetch(`${server}/api/v1${route}`, {
method, redirect: 'error', signal: AbortSignal.timeout(60_000),
headers: { Authorization: auth, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) },
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body),
});
if (missing && response.status === 404) return null;
if (!response.ok) {
const error = new Error(`Gitea ${method} ${route.split('?')[0]} returned HTTP ${response.status}`);
error.status = response.status;
throw error;
}
if (response.status === 204) return null;
return response.json();
}
// Keep credentials on this Gitea origin; release storage may redirect to a CDN.
export async function download(url, token = process.env.GITEA_TOKEN, limit = 512 * 1024 * 1024) {
let current = new URL(url);
for (let redirects = 0; redirects <= 5; redirects++) {
if (current.protocol !== 'https:' || current.username || current.password) throw new Error('Invalid release download URL');
const response = await fetch(current, {
redirect: 'manual', signal: AbortSignal.timeout(120_000),
headers: current.origin === server ? { Authorization: authorization(token) } : {},
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location');
await response.body?.cancel();
if (!location) throw new Error('Release redirect has no location');
current = new URL(location, current);
continue;
}
if (!response.ok) throw new Error(`Release download returned HTTP ${response.status}`);
const chunks = []; let size = 0;
for await (const chunk of response.body) {
size += chunk.length;
if (size > limit) throw new Error('Release download exceeds declared size limit');
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
throw new Error('Too many release download redirects');
}
let localAuthorization;
function authorization(token) {
if (token) return `token ${token}`;
if (process.env.GITEA_ACTIONS === 'true') throw new Error('Workflow token is missing');
if (!localAuthorization) {
let output;
try {
output = execFileSync('git', ['-c', 'credential.interactive=false', 'credential', 'fill'], {
input: `url=${server}\n\n`, encoding: 'utf8', timeout: 10_000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, stdio: ['pipe', 'pipe', 'ignore'],
});
} catch {
throw new Error('Set GITEA_TOKEN or configure Git HTTPS credentials');
}
const fields = Object.fromEntries(output.trim().split('\n').map(line => {
const separator = line.indexOf('='); return [line.slice(0, separator), line.slice(separator + 1)];
}));
if (!fields.username || !fields.password) throw new Error('Set GITEA_TOKEN or configure Git HTTPS credentials');
localAuthorization = `Basic ${Buffer.from(`${fields.username}:${fields.password}`).toString('base64')}`;
}
return localAuthorization;
}
export async function notifyCatalog(repository, tag) {
await api(`/repos/${repositoryPath(catalogRepository)}/actions/workflows/sync-release.yml/dispatches`, {
token: process.env.CATALOG_TOKEN,
method: 'POST', body: { ref: 'main', inputs: { repository, tag } },
});
console.log(`Requested catalog registration: ${repository}@${tag}`);
}
+7
View File
@@ -0,0 +1,7 @@
import { api, notifyCatalog, repositoryPath } from './gitea.mjs';
const repository = process.env.GITEA_REPOSITORY;
const tag = process.env.RELEASE_TAG;
if (!tag) throw new Error('RELEASE_TAG is required');
const release = await api(`/repos/${repositoryPath(repository)}/releases/tags/${encodeURIComponent(tag)}`);
if (release.draft || !release.assets.some(asset => asset.name === 'trove-release.json')) throw new Error('A completed release with trove-release.json is required');
await notifyCatalog(repository, tag);
+11
View File
@@ -0,0 +1,11 @@
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import { releasePlan } from './release-plan.mjs';
const { values } = parseArgs({ options: { entries: { type: 'string' }, tag: { type: 'string' } } });
if (!values.entries) throw new Error('--entries is required');
const { files, descriptor } = await releasePlan(values.entries, values.tag);
await rm('dist/release', { recursive: true, force: true });
await mkdir('dist/release', { recursive: true });
for (const [name, bytes] of files) await writeFile(`dist/release/${name}`, bytes);
console.log(`Prepared ${files.size} release files in dist/release/`);
console.log(`Create draft release ${descriptor.tag} at commit ${descriptor.commit}, upload every file, then publish.`);
+40
View File
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { api, download, repositoryPath } from './gitea.mjs';
const config = JSON.parse(await readFile('release.json', 'utf8'));
const descriptorBytes = await readFile('dist/release/trove-release.json');
const descriptor = JSON.parse(descriptorBytes);
const { repository, tag } = descriptor;
if (descriptor.schemaVersion !== 1 || repository !== config.repository) throw new Error('Unexpected release descriptor');
const route = `/repos/${repositoryPath(repository)}`;
const files = new Map();
for (const plugin of descriptor.plugins) {
for (const artifact of plugin.artifacts) {
if (!/^[a-zA-Z0-9._+-]+\.curio$/.test(artifact.asset)) throw new Error('Invalid asset filename');
const bytes = await readFile(`dist/release/${artifact.asset}`);
if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error('Package changed after release preparation');
files.set(artifact.asset, bytes);
}
}
files.set('trove-release.json', descriptorBytes);
let release = await api(`${route}/releases/tags/${encodeURIComponent(tag)}`, { missing: true });
if (!release) release = await api(`${route}/releases`, { method: 'POST', body: {
tag_name: tag, target_commitish: descriptor.commit, name: tag, draft: true,
prerelease: tag.slice(1).split('+')[0].includes('-'),
body: `${config.name} ${tag}\n\n通过 Trove 软件源安装和更新。`,
} });
for (const [name, bytes] of files) {
const existing = release.assets.find(asset => asset.name === name);
if (existing) {
const published = await download(existing.browser_download_url, undefined, bytes.length);
if (!published.equals(bytes)) throw new Error(`Release asset is immutable: ${name}; publish a new version`);
continue;
}
if (!release.draft) throw new Error(`Published release is incomplete: ${name}; publish a new version`);
const form = new FormData(); form.append('attachment', new Blob([bytes]), name);
const asset = await api(`${route}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: form });
release.assets.push(asset);
}
if (release.draft) await api(`${route}/releases/${release.id}`, { method: 'PATCH', body: { draft: false } });
console.log(`Published ${repository}@${tag}`);
console.log('The release workflow will register these packages in the catalog.');
+35
View File
@@ -0,0 +1,35 @@
import { gt } from 'semver';
// Published identity is (Curio ID, version, target). Existing identities are immutable.
export function appendRelease(catalog, manifest, artifact, releasedAt) {
const next = structuredClone(catalog);
let curio = next.curios.find(item => item.id === manifest.id);
if (!curio) {
curio = { id: manifest.id, name: manifest.name, description: manifest.description ?? '', ...(manifest.icon ? { icon: manifest.icon } : {}), versions: [] };
next.curios.push(curio);
}
let version = curio.versions.find(item => item.version === manifest.version);
if (version && version.engine.trove !== manifest.engine.trove) {
throw new Error(`${manifest.id}@${manifest.version}: published engine range cannot change; bump the plugin version`);
}
const existing = version?.artifacts.find(item => item.target === artifact.target);
if (existing) {
for (const key of ['sha256', 'size', 'url']) {
if (existing[key] !== artifact[key]) throw new Error(`${manifest.id}@${manifest.version}/${artifact.target}: published ${key} differs; bump the plugin version`);
}
return next;
}
if (!version) {
const newest = curio.versions.every(item => gt(manifest.version, item.version));
version = { version: manifest.version, engine: structuredClone(manifest.engine), releasedAt, artifacts: [] };
curio.versions.unshift(version);
if (newest) {
curio.name = manifest.name;
curio.description = manifest.description ?? '';
if (manifest.icon) curio.icon = manifest.icon;
}
}
version.artifacts.push(structuredClone(artifact));
next.generatedAt = next.generatedAt > releasedAt ? next.generatedAt : releasedAt;
return next;
}
+45
View File
@@ -0,0 +1,45 @@
import { readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { valid } from 'semver';
export async function releasePlan(entriesFile, tag) {
const config = JSON.parse(await readFile('release.json', 'utf8'));
const repository = config.repository;
if (!tag || !/^[a-zA-Z0-9][a-zA-Z0-9._+-]*$/.test(tag)) throw new Error('A release tag is required');
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const entries = JSON.parse(await readFile(entriesFile, 'utf8')).filter(entry => !entry.fault);
if (!entries.length) throw new Error('No packages to publish');
const plugins = new Map(); const files = new Map();
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
for (const entry of entries) {
const manifest = entry.manifest;
if (!config.ids.includes(manifest.id) || !valid(manifest.version)) throw new Error('Unexpected plugin identity/version');
if (tag !== `v${manifest.version}`) throw new Error('Release tag must match curio.json');
if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(entry.artifact.target)) throw new Error('Unsupported package target');
const bytes = await readFile(entry.file);
if (digest(bytes) !== entry.artifact.sha256 || bytes.length !== entry.artifact.size) throw new Error('Package differs from build metadata');
const asset = `${manifest.id}-${manifest.version}-${entry.artifact.target}.curio`;
if (files.has(asset)) throw new Error(`Duplicate release asset: ${asset}`);
files.set(asset, bytes);
const key = `${manifest.id}@${manifest.version}`;
if (!plugins.has(key)) {
let icon;
if (manifest.icon) {
const file = path.resolve(entry.directory, manifest.icon);
if (!file.startsWith(path.resolve(entry.directory) + path.sep)) throw new Error('Icon escapes package directory');
const data = await readFile(file);
const type = { '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[path.extname(file)];
if (type && data.length <= 64 * 1024) icon = `data:${type};base64,${data.toString('base64')}`;
}
plugins.set(key, { id: manifest.id, name: manifest.name, description: manifest.description ?? '', icon,
version: manifest.version, engine: manifest.engine, artifacts: [] });
}
plugins.get(key).artifacts.push({ target: entry.artifact.target, asset, sha256: entry.artifact.sha256, size: bytes.length });
}
const descriptor = Buffer.from(JSON.stringify({ schemaVersion: 1, repository, tag, commit, plugins: [...plugins.values()] }, null, 2) + '\n');
files.set('trove-release.json', descriptor);
return { files, descriptor: JSON.parse(descriptor) };
}
+80
View File
@@ -0,0 +1,80 @@
import { readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { parseArgs } from 'node:util';
import { valid, validRange, rcompare } from 'semver';
import { api, download, server, catalogRepository, repositoryPath } from './gitea.mjs';
import { appendRelease } from './release-catalog.mjs';
const { values } = parseArgs({ options: {
repository: { type: 'string' }, tag: { type: 'string' }, publish: { type: 'boolean', default: false },
} });
const repository = values.repository || process.env.RELEASE_REPOSITORY;
const tag = values.tag || process.env.RELEASE_TAG;
const config = JSON.parse(await readFile(new URL('../source/repositories.json', import.meta.url), 'utf8'));
const publisher = config.repositories.find(item => item.repository === repository);
if (!publisher || !tag) throw new Error('Repository is not registered in this catalog, or tag is missing');
const release = await 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 = JSON.parse(await download(descriptorAsset.browser_download_url, undefined, 1024 * 1024));
if (descriptor.schemaVersion !== 1 || descriptor.repository !== repository || descriptor.tag !== tag || !Array.isArray(descriptor.plugins) || !descriptor.plugins.length) throw new Error('Invalid release descriptor');
if (!/^[a-f0-9]{40,64}$/.test(descriptor.commit)) throw new Error('Invalid release commit');
const refs = await 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 api(`/repos/${repositoryPath(repository)}/git/tags/${object.sha}`)).object;
}
if (object?.type !== 'commit' || object.sha !== descriptor.commit) throw new Error('Release tag differs from package source commit');
const additions = []; const identities = new Set();
for (const plugin of descriptor.plugins) {
if (!publisher.ids.includes(plugin.id) || !valid(plugin.version) || !validRange(plugin.engine?.trove) || typeof plugin.name !== 'string' || !plugin.name.trim()) throw new Error('Unexpected plugin identity or compatibility range');
if (tag !== `v${plugin.version}`) throw new Error('Plugin version differs from release tag');
if (!Array.isArray(plugin.artifacts) || !plugin.artifacts.length) throw new Error('Plugin has no artifacts');
for (const artifact of plugin.artifacts) {
if (!['any', 'aarch64-apple-darwin', 'x86_64-apple-darwin'].includes(artifact.target) || !/^[a-f0-9]{64}$/.test(artifact.sha256) || !Number.isSafeInteger(artifact.size) || artifact.size < 1 || artifact.size > 512 * 1024 * 1024) throw new Error('Invalid artifact metadata');
const identity = `${plugin.id}@${plugin.version}/${artifact.target}`;
if (identities.has(identity)) throw new Error('Duplicate artifact identity');
identities.add(identity);
const asset = release.assets.find(asset => asset.name === artifact.asset);
if (!asset || asset.size !== artifact.size || new URL(asset.browser_download_url).origin !== server) throw new Error('Artifact is not a matching release attachment');
const bytes = await download(asset.browser_download_url, undefined, artifact.size);
if (bytes.length !== artifact.size || createHash('sha256').update(bytes).digest('hex') !== artifact.sha256) throw new Error(`Release checksum mismatch: ${identity}`);
additions.push({ plugin, artifact: { target: artifact.target, url: asset.browser_download_url, sha256: artifact.sha256, size: artifact.size } });
}
}
function merge(index) {
if (index.schemaVersion !== 1 || index.id !== config.id || !Array.isArray(index.curios)) throw new Error('Unexpected source identity');
for (const { plugin, artifact } of additions) {
index = appendRelease(index, plugin, artifact, release.published_at || release.created_at);
}
index.curios.sort((a, b) => a.id.localeCompare(b.id));
for (const plugin of index.curios) plugin.versions.sort((a, b) => rcompare(a.version, b.version));
return JSON.stringify(index, null, 2) + '\n';
}
if (!values.publish) {
const file = new URL('../source/index.json', import.meta.url);
await writeFile(file, merge(JSON.parse(await readFile(file, 'utf8'))));
console.log(`Updated local catalog with ${repository}@${tag}`);
} else {
// Gitea's contents SHA provides compare-and-swap. On concurrent publications,
// re-read and merge the latest index instead of overwriting another release.
const route = `/repos/${repositoryPath(catalogRepository)}/contents/source/index.json`;
for (let attempt = 0; attempt < 6; attempt++) {
const file = await api(`${route}?ref=main`, { token: process.env.CATALOG_WRITE_TOKEN });
const before = Buffer.from(file.content, 'base64').toString('utf8');
const content = merge(JSON.parse(before));
if (content === before) { console.log('Release already registered'); break; }
try {
await api(route, { token: process.env.CATALOG_WRITE_TOKEN, method: 'PUT', body: {
branch: 'main', sha: file.sha, content: Buffer.from(content).toString('base64'),
message: `chore(catalog): register ${repository}@${tag}`,
} });
console.log(`Registered ${repository}@${tag}`); break;
} catch (error) {
if (![409, 422].includes(error.status) || attempt === 5) throw error;
}
}
}