refactor: separate plugin repositories and release-based catalog
This commit is contained in:
@@ -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 (!publisher.suite && 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