import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { componentVersions, migrationManifest, stableTag } from './lib.mjs'; import semver from 'semver'; export function assertManifest(manifest, plan, config) { if (manifest.version !== plan.version || manifest.commit !== plan.sha || manifest.tag !== plan.tag || manifest.images.length !== config.images.length) throw new Error('Release-Manifest passt nicht zum Commit.'); for (const component of config.images) { const image = manifest.images.find(item => item.name === component.name); if (!image || image.repository !== `${config.registry}/${component.name}` || !/^sha256:[a-f0-9]{64}$/.test(image.digest)) { throw new Error(`Ungültiges Image im Manifest: ${component.name}`); } } } export async function makeManifest(plan, images, config) { return { schemaVersion: 1, version: plan.version, tag: plan.tag, commit: plan.sha, previousTag: plan.previousTag, repository: config.repositoryUrl, images, components: componentVersions(plan.sha), migrations: await migrationManifest(plan.sha), migrationPolicy: 'Forward migrations on startup. Downgrade requires schema compatibility or a matching database backup.', }; } export function releaseFiles(manifest, notes) { const pin = source => source.replaceAll('${FEDEO_VERSION:-dev}', `\${FEDEO_VERSION:-${manifest.version}}`); const files = new Map([ ['release-manifest.json', Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`)], ['CHANGELOG.md', Buffer.from(notes)], ['docker-compose.selfhost.yml', Buffer.from(pin(readFileSync('docker-compose.selfhost.yml', 'utf8')))], ['.env.example', Buffer.from(readFileSync('.env.example', 'utf8').replace(/^FEDEO_VERSION=.*$/m, `FEDEO_VERSION=${manifest.version}`))], ['selfhost-install.sh', Buffer.from(readFileSync('scripts/selfhost-install.sh', 'utf8').replace('FEDEO_REF:-dev', `FEDEO_REF:-${manifest.tag}`))], ['selfhost-setup.sh', Buffer.from(readFileSync('scripts/selfhost-setup.sh', 'utf8').replaceAll('FEDEO_VERSION:-dev', `FEDEO_VERSION:-${manifest.version}`))], ]); files.set('SHA256SUMS', Buffer.from([...files].map(([name, data]) => `${createHash('sha256').update(data).digest('hex')} ${name}`).join('\n') + '\n')); return files; } // Dependencies are explicit so failure/retry/publication ordering can be tested without a registry. export async function publishRelease({ plan, config, api, build, promote, files = releaseFiles, manifestFactory = makeManifest }) { const all = await api.releases(); const pending = all.find(release => release.draft && stableTag.test(release.tag_name) && release.tag_name !== plan.tag); if (pending) throw new Error(`Zuerst den unvollständigen Release ${pending.tag_name} wiederaufnehmen.`); const newer = all.find(release => !release.draft && stableTag.test(release.tag_name) && semver.gt(release.tag_name.slice(1), plan.version)); if (newer) throw new Error(`Ein neuerer Release ${newer.tag_name} existiert bereits; kein Downgrade von latest.`); let release = await api.release(plan.tag); const tag = await api.tag(plan.tag); if (tag && tag.commit.sha !== plan.sha) throw new Error(`${plan.tag} zeigt bereits auf einen anderen Commit.`); if (release && !tag && !release.draft) throw new Error('Release ohne Git-Tag; manuelle Prüfung erforderlich.'); if (release?.draft && release.target_commitish !== plan.sha) throw new Error('Release-Entwurf gehört zu einem anderen Commit.'); let manifest; let notes = plan.notes; if (release) { const saved = await api.asset(release, 'release-manifest.json'); if (saved) manifest = JSON.parse(saved.toString('utf8')); // Keep the original notes/date on retries, even on a later day. notes = release.body; if (!release.draft && !manifest) throw new Error('Veröffentlichter Release ohne Manifest; keine Images überschreiben.'); } if (!manifest) { const images = await build(plan.version); manifest = await manifestFactory(plan, images, config); } assertManifest(manifest, plan, config); if (!release) { release = await api.request('/releases', { method: 'POST', body: { tag_name: plan.tag, target_commitish: plan.sha, name: `FEDEO ${plan.version}`, body: notes, draft: true, prerelease: false, } }); } if (release.draft) { // Manifest is first: once any immutable image is promoted, every retry uses these exact digests. for (const [name, data] of files(manifest, notes)) await api.upload(release, name, data); for (const image of manifest.images) await promote(image, [plan.version, plan.tag, `sha-${plan.sha}`], true); release = await api.request(`/releases/${release.id}`, { method: 'PATCH', body: { draft: false } }); } // Gitea creates the Git tag when publishing, not when creating a draft. const publishedTag = await api.tag(plan.tag); if (!publishedTag || publishedTag.commit.sha !== plan.sha) throw new Error('Veröffentlichter Git-Tag passt nicht zum Release-Commit.'); // Mutable channels only move after all artifacts exist and the release is published. // A retry repairs an interrupted channel update without rebuilding any published version. for (const image of manifest.images) await promote(image, ['latest', 'main'], false); return { release, manifest }; }