Files
FEDEO/scripts/release/publish.mjs
flfeders 74c0cf40b3
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m50s
fix(release): stream large migration checksums from git
2026-09-23 15:37:19 +02:00

87 lines
5.2 KiB
JavaScript

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