89 lines
5.4 KiB
JavaScript
89 lines
5.4 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}`))],
|
|
['selfhost-update.py', Buffer.from(readFileSync('scripts/selfhost-update.py', 'utf8'))],
|
|
['SELFHOST-UPDATES.md', Buffer.from(readFileSync('docs/selfhost-updates.md', 'utf8'))],
|
|
]);
|
|
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 };
|
|
}
|