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