feat(release): automate versioned releases on main
This commit is contained in:
112
scripts/release/ci.mjs
Normal file
112
scripts/release/ci.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createPlan, git, readConfig, stableTag } from './lib.mjs';
|
||||
import { Gitea } from './gitea.mjs';
|
||||
import { acquireLock } from './lock.mjs';
|
||||
import { publishRelease } from './publish.mjs';
|
||||
|
||||
const config = readConfig();
|
||||
const outputDir = '.release';
|
||||
let unlock;
|
||||
|
||||
function docker(args, capture = false) {
|
||||
const result = spawnSync('docker', args, { encoding: 'utf8', stdio: capture ? 'pipe' : 'inherit', maxBuffer: 16 * 1024 * 1024 });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Docker ${args.slice(0, 3).join(' ')} fehlgeschlagen${capture ? `: ${result.stderr}` : '.'}`);
|
||||
return result.stdout?.trim();
|
||||
}
|
||||
|
||||
function imageDigest(reference, optional = false) {
|
||||
try {
|
||||
return JSON.parse(docker(['buildx', 'imagetools', 'inspect', reference, '--format', '{{json .Manifest}}'], true)).digest;
|
||||
} catch (error) {
|
||||
if (optional && /manifest unknown|not found|no such manifest/i.test(error.message)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function promote(image, tags, immutable) {
|
||||
for (const tag of tags) {
|
||||
const reference = `${image.repository}:${tag}`;
|
||||
if (immutable) {
|
||||
const existing = imageDigest(reference, true);
|
||||
if (existing === image.digest) continue;
|
||||
if (existing) throw new Error(`Image ${reference} existiert mit anderem Digest; Überschreiben verweigert.`);
|
||||
}
|
||||
docker(['buildx', 'imagetools', 'create', '--prefer-index=false', '--tag', reference, `${image.repository}@${image.digest}`]);
|
||||
if (imageDigest(reference) !== image.digest) throw new Error(`Digest-Prüfung für ${reference} fehlgeschlagen.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildImages(plan, version, reuseTag) {
|
||||
const stagingTag = `build-${randomUUID()}`;
|
||||
const images = [];
|
||||
for (const component of config.images) {
|
||||
const repository = `${config.registry}/${component.name}`;
|
||||
const existing = reuseTag ? imageDigest(`${repository}:${reuseTag}`, true) : null;
|
||||
if (existing) {
|
||||
images.push({ name: component.name, repository, digest: existing });
|
||||
continue;
|
||||
}
|
||||
const metadata = `${outputDir}/${component.name}.json`;
|
||||
console.log(`Baue ${component.name} ${version} (${plan.sha.slice(0, 12)})`);
|
||||
docker(['buildx', 'build', '--push', '--platform', 'linux/amd64', '--provenance=false',
|
||||
'--file', component.dockerfile, '--metadata-file', metadata, '--tag', `${repository}:${stagingTag}`,
|
||||
'--build-arg', `FEDEO_VERSION=${version}`, '--build-arg', `FEDEO_REVISION=${plan.sha}`,
|
||||
'--label', `org.opencontainers.image.version=${version}`, '--label', `org.opencontainers.image.revision=${plan.sha}`,
|
||||
'--label', `org.opencontainers.image.source=${config.repositoryUrl}`, component.context]);
|
||||
const digest = JSON.parse(readFileSync(metadata, 'utf8'))['containerimage.digest'];
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(digest || '')) throw new Error(`Fehlender Digest für ${component.name}.`);
|
||||
images.push({ name: component.name, repository, digest });
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
try {
|
||||
const ref = process.env.FEDEO_REF;
|
||||
if (process.env.CI !== 'true' || !['push', 'workflow_dispatch'].includes(process.env.FEDEO_EVENT_NAME) ||
|
||||
![`refs/heads/${config.branch}`, 'refs/heads/dev'].includes(ref)) {
|
||||
throw new Error('Veröffentlichung nur im Gitea-Workflow auf dev/main. Lokal: npm run release:preview.');
|
||||
}
|
||||
const sha = git(['rev-parse', 'HEAD']);
|
||||
if (sha !== process.env.FEDEO_SHA) throw new Error('Checkout stimmt nicht mit dem Workflow-Commit überein.');
|
||||
const token = process.env.GITEA_TOKEN;
|
||||
if (!token) throw new Error('CI_TOKEN oder RELEASE_TOKEN als Gitea-Secret konfigurieren (siehe docs/releases.md).');
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const api = new Gitea(config.repositoryUrl, token);
|
||||
const stable = ref === `refs/heads/${config.branch}`;
|
||||
unlock = await acquireLock(stable ? {} : { ref: 'refs/heads/automation/dev-build-lock' });
|
||||
git(['fetch', 'origin', `refs/heads/${config.branch}:refs/remotes/origin/${config.branch}`, '--tags']);
|
||||
|
||||
if (stable) {
|
||||
const releases = await api.releases();
|
||||
const pendingForThisCommit = releases.some(release => release.draft && stableTag.test(release.tag_name) && release.target_commitish === sha);
|
||||
if (git(['rev-parse', `origin/${config.branch}`]) !== sha && !pendingForThisCommit) {
|
||||
console.log('Ein neuerer main-Commit liegt vor. Dessen Workflow übernimmt die ausstehenden Änderungen.');
|
||||
} else {
|
||||
const plan = await createPlan();
|
||||
writeFileSync(`${outputDir}/plan.json`, JSON.stringify(plan, null, 2));
|
||||
if (!plan.version) console.log('Keine releasewirksamen Änderungen seit dem letzten Tag.');
|
||||
else {
|
||||
const result = await publishRelease({ plan, config, api, build: version => buildImages(plan, version), promote });
|
||||
writeFileSync(`${outputDir}/release-manifest.json`, JSON.stringify(result.manifest, null, 2));
|
||||
console.log(`FEDEO ${plan.version} veröffentlicht: ${config.repositoryUrl}/releases/tag/${plan.tag}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const plan = await createPlan();
|
||||
const images = await buildImages(plan, plan.developmentVersion, `dev-sha-${sha}`);
|
||||
for (const image of images) await promote(image, [`dev-sha-${sha}`], true);
|
||||
const remoteDev = git(['ls-remote', 'origin', 'refs/heads/dev']).split(/\s/)[0];
|
||||
if (remoteDev === sha) for (const image of images) await promote(image, ['dev'], false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (unlock) {
|
||||
try { unlock(); }
|
||||
catch (error) { console.error(`Release-Sperre konnte nicht entfernt werden: ${error.message}`); process.exitCode = 1; }
|
||||
}
|
||||
}
|
||||
21
scripts/release/cli.mjs
Normal file
21
scripts/release/cli.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { assertBootstrap, checkCommits, commitsSince, createPlan, readConfig } from './lib.mjs';
|
||||
|
||||
try {
|
||||
const command = process.argv[2];
|
||||
if (command === 'check') {
|
||||
const config = readConfig();
|
||||
assertBootstrap(config, 'HEAD', process.cwd());
|
||||
const commits = commitsSince(config.bootstrapCommit);
|
||||
checkCommits(commits);
|
||||
console.log(`${commits.length} Commit-Nachrichten geprüft.`);
|
||||
} else if (command === 'preview') {
|
||||
const plan = await createPlan();
|
||||
console.log(JSON.stringify(plan, null, 2));
|
||||
console.log('\nLokale Vorschau; berücksichtigt die lokal vorhandenen Tags. Zum Aktualisieren: git fetch origin --tags.');
|
||||
} else {
|
||||
throw new Error('Aufruf: node scripts/release/cli.mjs check|preview');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
68
scripts/release/gitea.mjs
Normal file
68
scripts/release/gitea.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
export class Gitea {
|
||||
constructor(repositoryUrl, token, fetchImpl = fetch) {
|
||||
const url = new URL(repositoryUrl);
|
||||
this.origin = url.origin;
|
||||
this.base = `${url.origin}/api/v1/repos${url.pathname.replace(/\/$/, '')}`;
|
||||
this.token = token;
|
||||
this.fetch = fetchImpl;
|
||||
}
|
||||
|
||||
async request(path, { method = 'GET', body, missing = false, raw = false } = {}) {
|
||||
const response = await this.fetch(`${this.base}${path}`, {
|
||||
method, redirect: 'error', signal: AbortSignal.timeout(60_000),
|
||||
headers: { Authorization: `token ${this.token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) },
|
||||
body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (missing && response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`Gitea ${method} ${path}: HTTP ${response.status}`);
|
||||
if (raw) return Buffer.from(await response.arrayBuffer());
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
|
||||
release(tag) { return this.request(`/releases/tags/${encodeURIComponent(tag)}`, { missing: true }); }
|
||||
tag(tag) { return this.request(`/tags/${encodeURIComponent(tag)}`, { missing: true }); }
|
||||
|
||||
async releases() {
|
||||
const releases = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await this.request(`/releases?limit=50&page=${page}`);
|
||||
releases.push(...batch);
|
||||
if (batch.length < 50) return releases;
|
||||
}
|
||||
}
|
||||
|
||||
async asset(release, name) {
|
||||
const assets = await this.request(`/releases/${release.id}/assets`);
|
||||
const asset = assets.find(item => item.name === name);
|
||||
if (!asset) return null;
|
||||
let url = new URL(asset.browser_download_url);
|
||||
if (url.origin !== this.origin) throw new Error('Unerwartete Herkunft der Release-Datei.');
|
||||
for (let redirects = 0; redirects < 5; redirects++) {
|
||||
const response = await this.fetch(url.href, {
|
||||
redirect: 'manual', signal: AbortSignal.timeout(60_000),
|
||||
headers: url.origin === this.origin ? { Authorization: `token ${this.token}` } : {},
|
||||
});
|
||||
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
||||
url = new URL(response.headers.get('location'), url);
|
||||
if (url.protocol !== 'https:') throw new Error('Unsicheres Download-Redirect.');
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Release-Download: HTTP ${response.status}`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
throw new Error('Zu viele Download-Redirects.');
|
||||
}
|
||||
|
||||
async upload(release, name, data) {
|
||||
const assets = await this.request(`/releases/${release.id}/assets`);
|
||||
const existing = assets.find(item => item.name === name);
|
||||
if (existing) {
|
||||
const saved = await this.asset(release, name);
|
||||
if (!saved.equals(Buffer.from(data))) throw new Error(`Release-Datei ${name} existiert bereits mit anderem Inhalt.`);
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('attachment', new Blob([data]), name);
|
||||
await this.request(`/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: form });
|
||||
}
|
||||
}
|
||||
93
scripts/release/lib.mjs
Normal file
93
scripts/release/lib.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { analyzeCommits } from '@semantic-release/commit-analyzer';
|
||||
import { generateNotes } from '@semantic-release/release-notes-generator';
|
||||
import semver from 'semver';
|
||||
|
||||
export const logger = { log() {} };
|
||||
export const stableTag = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
const commitHeader = /^(feat|fix|perf|docs|test|refactor|build|ci|chore|style|revert)(\([^()\r\n]+\))?!?: \S.*$/;
|
||||
|
||||
export function git(args, cwd = process.cwd(), options = {}) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...options }).trim();
|
||||
}
|
||||
|
||||
export function readConfig(cwd = process.cwd()) {
|
||||
return JSON.parse(readFileSync(resolve(cwd, 'release.config.json'), 'utf8'));
|
||||
}
|
||||
|
||||
export function validCommit(message) {
|
||||
// Git's generated revert message is understood by the Conventional Commits preset.
|
||||
return commitHeader.test(message.split('\n')[0]) ||
|
||||
(/^Revert ".+"$/.test(message.split('\n')[0]) && /^This reverts commit [a-f0-9]{40}\.$/m.test(message));
|
||||
}
|
||||
|
||||
export function commitsSince(from, to = 'HEAD', cwd = process.cwd()) {
|
||||
// semantic-release's revert filter expects newest commits first.
|
||||
const hashes = git(['rev-list', '--no-merges', `${from}..${to}`], cwd);
|
||||
return hashes ? hashes.split('\n').map(hash => ({ hash, message: git(['show', '-s', '--format=%B', hash], cwd) })) : [];
|
||||
}
|
||||
|
||||
export function checkCommits(commits) {
|
||||
const invalid = commits.filter(commit => !validCommit(commit.message));
|
||||
if (invalid.length) throw new Error(`Conventional Commits fehlen:\n${invalid.map(c => `${c.hash.slice(0, 8)} ${c.message.split('\n')[0]}`).join('\n')}\nVerwende z. B. fix(scope): Beschreibung oder feat(scope): Beschreibung.`);
|
||||
}
|
||||
|
||||
export async function releaseType(commits, cwd = process.cwd()) {
|
||||
return analyzeCommits({ preset: 'conventionalcommits' }, { cwd, commits, logger });
|
||||
}
|
||||
|
||||
export function assertBootstrap(config, target, cwd) {
|
||||
try { git(['merge-base', '--is-ancestor', config.bootstrapCommit, target], cwd); }
|
||||
catch { throw new Error('Der Release-Ausgangscommit fehlt in der Historie. Vollständig auschecken (fetch-depth: 0) und dev nach main übernehmen.'); }
|
||||
}
|
||||
|
||||
export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd), target = 'HEAD' } = {}) {
|
||||
const sha = git(['rev-parse', `${target}^{commit}`], cwd);
|
||||
assertBootstrap(config, sha, cwd);
|
||||
const tags = git(['tag', '--merged', sha], cwd).split('\n').filter(tag => stableTag.test(tag))
|
||||
.sort((a, b) => semver.rcompare(a.slice(1), b.slice(1)));
|
||||
const lastTag = tags[0];
|
||||
const resume = !!lastTag && git(['rev-parse', `${lastTag}^{commit}`], cwd) === sha;
|
||||
const previousTag = resume ? tags[1] : lastTag;
|
||||
const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit;
|
||||
const commits = commitsSince(from, sha, cwd);
|
||||
checkCommits(commitsSince(config.bootstrapCommit, sha, cwd));
|
||||
const bump = await releaseType(commits, cwd);
|
||||
const version = resume ? lastTag.slice(1) : !lastTag ? config.initialVersion : bump ? semver.inc(lastTag.slice(1), bump) : null;
|
||||
if (version && !semver.valid(version)) throw new Error('Ungültige Release-Version.');
|
||||
const tag = version ? `v${version}` : null;
|
||||
const notes = version ? await generateNotes({
|
||||
preset: 'conventionalcommits', host: new URL(config.repositoryUrl).origin,
|
||||
commit: 'commit', issue: 'issues', linkCompare: Boolean(previousTag),
|
||||
}, {
|
||||
cwd, logger, commits, options: { repositoryUrl: config.repositoryUrl },
|
||||
lastRelease: previousTag ? { version: previousTag.slice(1), gitTag: previousTag, gitHead: from } : {},
|
||||
nextRelease: { version, gitTag: tag, gitHead: sha },
|
||||
}) : '';
|
||||
const count = git(['rev-list', '--count', `${config.bootstrapCommit}..${sha}`], cwd);
|
||||
return {
|
||||
sha, version, tag, previousTag: previousTag || null, from, bump, resume,
|
||||
developmentVersion: `${lastTag ? lastTag.slice(1) : '0.0.0'}-dev.${count}.g${sha.slice(0, 12)}`,
|
||||
notes: version ? `${!previousTag ? 'Erster versionierter FEDEO-Release. Die ältere Entwicklungshistorie bleibt im Git-Repository erhalten.\n\n' : ''}${notes}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function componentVersions(sha, cwd = process.cwd()) {
|
||||
const json = path => JSON.parse(git(['show', `${sha}:${path}`], cwd));
|
||||
return {
|
||||
mobile: { version: json('mobile/app.json').expo.version, iosBuildNumber: json('mobile/app.json').expo.ios.buildNumber },
|
||||
deviceAgent: { version: json('agents/fedeo-device-agent/package.json').version },
|
||||
};
|
||||
}
|
||||
|
||||
export function migrationManifest(sha, cwd = process.cwd()) {
|
||||
return ['backend/db/migrations', 'push-server/packages/db/drizzle'].flatMap(folder => {
|
||||
const files = git(['ls-tree', '-r', '--name-only', sha, '--', folder], cwd);
|
||||
return files ? files.split('\n').filter(path => path.endsWith('.sql')).map(path => ({
|
||||
path, sha256: createHash('sha256').update(execFileSync('git', ['show', `${sha}:${path}`], { cwd })).digest('hex'),
|
||||
})) : [];
|
||||
});
|
||||
}
|
||||
25
scripts/release/lock.mjs
Normal file
25
scripts/release/lock.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { git } from './lib.mjs';
|
||||
|
||||
export const lockRef = 'refs/heads/automation/release-lock';
|
||||
|
||||
export async function acquireLock({ runGit = git, wait = sleep, attempts = 480, ref = lockRef } = {}) {
|
||||
const lockSha = runGit(['-c', 'user.name=FEDEO Release', '-c', 'user.email=release@fedeo.de',
|
||||
'commit-tree', 'HEAD^{tree}', '-p', 'HEAD', '-m', `chore(release): lock ${randomUUID()}`]);
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const existing = runGit(['ls-remote', 'origin', ref]);
|
||||
if (!existing) {
|
||||
try {
|
||||
// An empty lease means the ref MUST NOT exist, even if another runner just created it.
|
||||
runGit(['push', `--force-with-lease=${ref}:`, 'origin', `${lockSha}:${ref}`]);
|
||||
return () => runGit(['push', `--force-with-lease=${ref}:${lockSha}`, 'origin', `:${ref}`]);
|
||||
} catch (error) {
|
||||
if (!runGit(['ls-remote', 'origin', ref])) throw error;
|
||||
}
|
||||
}
|
||||
if (attempt % 4 === 0) console.log('Ein anderer Release läuft; warte auf die Git-Sperre.');
|
||||
await wait(15_000);
|
||||
}
|
||||
throw new Error(`Release-Sperre ${ref} bleibt belegt. Siehe docs/releases.md zur Wiederaufnahme.`);
|
||||
}
|
||||
86
scripts/release/publish.mjs
Normal file
86
scripts/release/publish.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
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 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: 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 = 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 };
|
||||
}
|
||||
128
scripts/release/tests/planning.test.mjs
Normal file
128
scripts/release/tests/planning.test.mjs
Normal file
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { checkCommits, createPlan, git, releaseType, validCommit } from '../lib.mjs';
|
||||
import { acquireLock, lockRef } from '../lock.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'fedeo-release-test-'));
|
||||
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
||||
symlinkSync(resolve('node_modules'), join(cwd, 'node_modules'), 'dir');
|
||||
const run = args => git(args, cwd, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
run(['init', '-b', 'main']);
|
||||
run(['config', 'user.name', 'Release Test']);
|
||||
run(['config', 'user.email', 'release@example.test']);
|
||||
run(['commit', '--allow-empty', '-m', 'historic baseline']);
|
||||
const config = { bootstrapCommit: run(['rev-parse', 'HEAD']), initialVersion: '1.0.0', repositoryUrl: 'https://git.example.test/owner/fedeo' };
|
||||
let count = 0;
|
||||
const commit = message => {
|
||||
writeFileSync(join(cwd, 'file'), `${++count}\n`);
|
||||
run(['add', 'file']);
|
||||
run(['commit', '-m', message]);
|
||||
return run(['rev-parse', 'HEAD']);
|
||||
};
|
||||
return { cwd, config, commit, run, plan: () => createPlan({ cwd, config }) };
|
||||
}
|
||||
|
||||
test('commit types, scopes and breaking headers are enforced', () => {
|
||||
for (const message of ['fix: repair', 'feat(stock): add transfers', 'feat(api)!: replace auth', 'ci: build', 'revert: restore']) assert.ok(validCommit(message));
|
||||
for (const message of ['changes', 'Feat: uppercase', 'fix:', 'fix(x) missing colon', 'fix: ']) assert.equal(validCommit(message), false);
|
||||
assert.throws(() => checkCommits([{ hash: 'abc123', message: 'misc changes' }]), /Conventional Commits/);
|
||||
});
|
||||
|
||||
test('semantic-release determines the highest required bump and handles maintenance', async () => {
|
||||
const commit = message => ({ hash: 'a'.repeat(40), message });
|
||||
assert.equal(await releaseType([commit('fix: repair')]), 'patch');
|
||||
assert.equal(await releaseType([commit('perf: reduce latency')]), 'patch');
|
||||
assert.equal(await releaseType([commit('fix: repair'), commit('feat: add export')]), 'minor');
|
||||
assert.equal(await releaseType([commit('feat(api)!: replace auth')]), 'major');
|
||||
assert.equal(await releaseType([commit('refactor: change API\n\nBREAKING CHANGE: remove old API')]), 'major');
|
||||
assert.equal(await releaseType([commit('docs: improve guide'), commit('ci: check build')]), null);
|
||||
});
|
||||
|
||||
test('first release uses explicit baseline and excludes legacy history', async t => {
|
||||
const f = fixture(t);
|
||||
f.commit('feat: export');
|
||||
const plan = await f.plan();
|
||||
assert.equal(plan.version, '1.0.0');
|
||||
assert.equal(plan.tag, 'v1.0.0');
|
||||
assert.match(plan.notes, /export/);
|
||||
assert.doesNotMatch(plan.notes, /historic baseline|compare\/\.\.\./);
|
||||
});
|
||||
|
||||
test('patch/minor/major releases, multi-digit tags and unrelated component tags', async t => {
|
||||
const f = fixture(t);
|
||||
f.run(['tag', 'v1.9.0']);
|
||||
f.run(['tag', 'v1.10.0']);
|
||||
f.run(['tag', 'mobile-v99.0.0']);
|
||||
f.commit('fix: repair');
|
||||
assert.equal((await f.plan()).version, '1.10.1');
|
||||
f.commit('feat: add transfer');
|
||||
assert.equal((await f.plan()).version, '1.11.0');
|
||||
f.commit('fix(api)!: remove field');
|
||||
assert.equal((await f.plan()).version, '2.0.0');
|
||||
});
|
||||
|
||||
test('maintenance does not release; same tagged commit resumes the same version', async t => {
|
||||
const f = fixture(t);
|
||||
f.run(['tag', 'v1.0.0']);
|
||||
f.commit('docs: guide');
|
||||
assert.equal((await f.plan()).version, null);
|
||||
f.commit('feat: export');
|
||||
f.run(['tag', 'v1.1.0']);
|
||||
const plan = await f.plan();
|
||||
assert.equal(plan.version, '1.1.0');
|
||||
assert.equal(plan.resume, true);
|
||||
assert.equal(plan.previousTag, 'v1.0.0');
|
||||
assert.match(plan.notes, /export/);
|
||||
});
|
||||
|
||||
test('reverted changes cancel out within the same release range', async t => {
|
||||
const f = fixture(t);
|
||||
f.run(['tag', 'v1.0.0']);
|
||||
const sha = f.commit('feat: temporary feature');
|
||||
f.run(['revert', '--no-edit', sha]);
|
||||
assert.equal((await f.plan()).version, null);
|
||||
});
|
||||
|
||||
test('real merge commits do not replace the feature commits in release notes', async t => {
|
||||
const f = fixture(t);
|
||||
f.run(['checkout', '-b', 'feature']);
|
||||
f.commit('feat: new feature');
|
||||
f.run(['checkout', 'main']);
|
||||
f.run(['merge', '--no-ff', 'feature', '-m', 'Merge feature']);
|
||||
const plan = await f.plan();
|
||||
assert.match(plan.notes, /new feature/);
|
||||
assert.doesNotMatch(plan.notes, /Merge feature/);
|
||||
});
|
||||
|
||||
test('an unknown baseline fails instead of releasing unrelated history', async t => {
|
||||
const f = fixture(t);
|
||||
f.config.bootstrapCommit = 'f'.repeat(40);
|
||||
await assert.rejects(f.plan(), /Ausgangscommit/);
|
||||
});
|
||||
|
||||
test('Git lease lock excludes concurrent runners and cannot remove another owner', async t => {
|
||||
const f = fixture(t);
|
||||
const remote = join(f.cwd, 'remote.git');
|
||||
f.run(['init', '--bare', remote]);
|
||||
f.run(['remote', 'add', 'origin', remote]);
|
||||
const unlock = await acquireLock({ runGit: f.run, wait: async () => {}, attempts: 1 });
|
||||
await assert.rejects(acquireLock({ runGit: f.run, wait: async () => {}, attempts: 1 }), /belegt/);
|
||||
const replacement = f.commit('chore: other lock owner');
|
||||
f.run(['push', '--force', 'origin', `${replacement}:${lockRef}`]);
|
||||
assert.throws(unlock);
|
||||
assert.match(f.run(['ls-remote', 'origin', lockRef]), new RegExp(replacement));
|
||||
});
|
||||
|
||||
test('Git lease lock is released after a successful run', async t => {
|
||||
const f = fixture(t);
|
||||
const remote = join(f.cwd, 'remote.git');
|
||||
f.run(['init', '--bare', remote]);
|
||||
f.run(['remote', 'add', 'origin', remote]);
|
||||
const unlock = await acquireLock({ runGit: f.run, attempts: 1 });
|
||||
unlock();
|
||||
assert.equal(f.run(['ls-remote', 'origin', lockRef]), '');
|
||||
});
|
||||
166
scripts/release/tests/publication.test.mjs
Normal file
166
scripts/release/tests/publication.test.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { publishRelease, assertManifest, releaseFiles } from '../publish.mjs';
|
||||
import { Gitea } from '../gitea.mjs';
|
||||
|
||||
function fixture() {
|
||||
const plan = { version: '1.2.0', tag: 'v1.2.0', sha: 'a'.repeat(40), notes: 'Release notes' };
|
||||
const config = { registry: 'registry.test/fedeo', images: [{ name: 'backend' }, { name: 'frontend' }] };
|
||||
const images = config.images.map((item, i) => ({ ...item, repository: `${config.registry}/${item.name}`, digest: `sha256:${String(i).repeat(64)}` }));
|
||||
const manifest = { version: plan.version, tag: plan.tag, commit: plan.sha, images };
|
||||
const events = [];
|
||||
let release = null;
|
||||
let tag = null;
|
||||
const assets = new Map();
|
||||
const api = {
|
||||
async releases() { return release ? [release] : []; },
|
||||
async release() { return release; },
|
||||
async tag() { return tag; },
|
||||
async asset(_release, name) { return assets.get(name); },
|
||||
async upload(_release, name, bytes) {
|
||||
events.push(`upload:${name}`);
|
||||
if (assets.has(name)) assert.deepEqual(assets.get(name), bytes);
|
||||
assets.set(name, bytes);
|
||||
},
|
||||
async request(_path, { method, body }) {
|
||||
if (method === 'POST') {
|
||||
events.push('draft');
|
||||
// Gitea drafts deliberately do NOT create a tag.
|
||||
release = { ...body, id: 1 };
|
||||
} else {
|
||||
events.push('publish');
|
||||
release = { ...release, ...body };
|
||||
tag = { commit: { sha: plan.sha } };
|
||||
}
|
||||
return release;
|
||||
},
|
||||
};
|
||||
const dependencies = {
|
||||
plan, config, api,
|
||||
build: async () => { events.push('build'); return images; },
|
||||
promote: async (image, _tags, immutable) => { events.push(`${immutable ? 'version' : 'channel'}:${image.name}`); },
|
||||
manifestFactory: () => manifest,
|
||||
files: (saved, notes) => new Map([['release-manifest.json', Buffer.from(JSON.stringify(saved))], ['CHANGELOG.md', Buffer.from(notes)]]),
|
||||
};
|
||||
return { plan, config, manifest, api, events, assets, dependencies, getRelease: () => release, setTag: value => { tag = value; } };
|
||||
}
|
||||
|
||||
test('publish only after builds, assets and immutable images; channels move last', async () => {
|
||||
const f = fixture();
|
||||
await publishRelease(f.dependencies);
|
||||
assert.deepEqual(f.events, ['build', 'draft', 'upload:release-manifest.json', 'upload:CHANGELOG.md', 'version:backend', 'version:frontend', 'publish', 'channel:backend', 'channel:frontend']);
|
||||
});
|
||||
|
||||
test('failed build never creates a draft or changes release channels', async () => {
|
||||
const f = fixture();
|
||||
f.dependencies.build = async () => { throw new Error('build failed'); };
|
||||
await assert.rejects(publishRelease(f.dependencies), /build failed/);
|
||||
assert.deepEqual(f.events, []);
|
||||
});
|
||||
|
||||
test('retry a draft uses saved digests and original notes without rebuilding', async () => {
|
||||
const f = fixture();
|
||||
f.dependencies.promote = async () => { throw new Error('registry interrupted'); };
|
||||
await assert.rejects(publishRelease(f.dependencies), /registry interrupted/);
|
||||
assert.equal(f.getRelease().draft, true);
|
||||
assert.equal(await f.api.tag(), null);
|
||||
f.plan.notes = 'Notes generated on another day';
|
||||
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
|
||||
f.dependencies.promote = async () => {};
|
||||
await publishRelease(f.dependencies);
|
||||
assert.equal(f.getRelease().draft, false);
|
||||
assert.equal(f.assets.get('CHANGELOG.md').toString(), 'Release notes');
|
||||
});
|
||||
|
||||
test('retry after publication only repairs mutable channels', async () => {
|
||||
const f = fixture();
|
||||
const promote = f.dependencies.promote;
|
||||
f.dependencies.promote = async (image, tags, immutable) => {
|
||||
if (!immutable) throw new Error('channel update interrupted');
|
||||
await promote(image, tags, immutable);
|
||||
};
|
||||
await assert.rejects(publishRelease(f.dependencies), /channel update interrupted/);
|
||||
f.events.length = 0;
|
||||
f.dependencies.promote = promote;
|
||||
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
|
||||
await publishRelease(f.dependencies);
|
||||
assert.deepEqual(f.events, ['channel:backend', 'channel:frontend']);
|
||||
});
|
||||
|
||||
test('partial asset upload remains a draft and retries safely', async () => {
|
||||
const f = fixture();
|
||||
const upload = f.api.upload;
|
||||
f.api.upload = async (...args) => {
|
||||
if (args[1] === 'CHANGELOG.md') throw new Error('upload interrupted');
|
||||
return upload(...args);
|
||||
};
|
||||
await assert.rejects(publishRelease(f.dependencies), /upload interrupted/);
|
||||
assert.equal(f.getRelease().draft, true);
|
||||
assert.ok(!f.events.includes('publish'));
|
||||
f.api.upload = upload;
|
||||
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
|
||||
await publishRelease(f.dependencies);
|
||||
});
|
||||
|
||||
test('another pending release must be completed first', async () => {
|
||||
const f = fixture();
|
||||
f.api.releases = async () => [{ draft: true, tag_name: 'v1.1.0' }];
|
||||
await assert.rejects(publishRelease(f.dependencies), /unvollständigen Release/);
|
||||
assert.deepEqual(f.events, []);
|
||||
});
|
||||
|
||||
test('a historical rerun cannot roll latest back', async () => {
|
||||
const f = fixture();
|
||||
f.api.releases = async () => [{ draft: false, tag_name: 'v2.0.0' }];
|
||||
await assert.rejects(publishRelease(f.dependencies), /Downgrade/);
|
||||
});
|
||||
|
||||
test('tag collisions and manifests with wrong digests or commits are rejected', async () => {
|
||||
const f = fixture();
|
||||
f.setTag({ commit: { sha: 'b'.repeat(40) } });
|
||||
await assert.rejects(publishRelease(f.dependencies), /anderen Commit/);
|
||||
assert.throws(() => assertManifest({ ...f.manifest, commit: 'bad' }, f.plan, f.config));
|
||||
const malformed = structuredClone(f.manifest);
|
||||
malformed.images[0].digest = 'latest';
|
||||
assert.throws(() => assertManifest(malformed, f.plan, f.config));
|
||||
});
|
||||
|
||||
test('release assets pin compose, environment and installer to one version', () => {
|
||||
const f = fixture();
|
||||
const files = releaseFiles(f.manifest, f.plan.notes);
|
||||
assert.match(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-1\.2\.0/);
|
||||
assert.doesNotMatch(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-dev/);
|
||||
assert.match(files.get('.env.example').toString(), /^FEDEO_VERSION=1\.2\.0$/m);
|
||||
assert.match(files.get('selfhost-install.sh').toString(), /FEDEO_REF:-v1\.2\.0/);
|
||||
assert.equal(files.get('SHA256SUMS').toString().trim().split('\n').length, files.size - 1);
|
||||
});
|
||||
|
||||
test('Gitea API differentiates missing resources from permission and server errors', async () => {
|
||||
const api = new Gitea('https://git.example.test/owner/repo', 'secret', async () => new Response('', { status: 404 }));
|
||||
assert.equal(await api.release('v1.0.0'), null);
|
||||
api.fetch = async () => new Response('', { status: 403 });
|
||||
await assert.rejects(api.release('v1.0.0'), /HTTP 403/);
|
||||
api.fetch = async () => new Response('', { status: 500 });
|
||||
await assert.rejects(api.release('v1.0.0'), /HTTP 500/);
|
||||
});
|
||||
|
||||
test('Gitea download follows object-storage redirects without leaking the token', async () => {
|
||||
const requests = [];
|
||||
const api = new Gitea('https://git.example.test/owner/repo', 'secret', async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
if (url.endsWith('/assets')) return Response.json([{ id: 2, name: 'manifest', browser_download_url: 'https://git.example.test/attachments/uuid' }]);
|
||||
if (url.includes('/attachments/')) return new Response(null, { status: 302, headers: { location: 'https://objects.example.test/signed-file' } });
|
||||
return new Response('file bytes');
|
||||
});
|
||||
assert.equal((await api.asset({ id: 1 }, 'manifest')).toString(), 'file bytes');
|
||||
assert.equal(requests[1].options.headers.Authorization, 'token secret');
|
||||
assert.deepEqual(requests[2].options.headers, {});
|
||||
});
|
||||
|
||||
test('Gitea upload retries compare existing bytes and refuse conflicting assets', async () => {
|
||||
const api = new Gitea('https://git.example.test/owner/repo', 'secret');
|
||||
api.request = async () => [{ id: 1, name: 'manifest' }];
|
||||
api.asset = async () => Buffer.from('original');
|
||||
await api.upload({ id: 1 }, 'manifest', Buffer.from('original'));
|
||||
await assert.rejects(api.upload({ id: 1 }, 'manifest', Buffer.from('changed')), /anderem Inhalt/);
|
||||
});
|
||||
Reference in New Issue
Block a user