chore(catalog): initialize production plugin source
This commit is contained in:
@@ -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
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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.`);
|
||||
@@ -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.');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user