Files
curios/tools/sync-release.mjs
T

81 lines
5.6 KiB
JavaScript

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;
}
}
}