6 Commits

Author SHA1 Message Date
2e63af1aa2 Merge pull request 'fix(release): stream large migration checksums from git' (#225) from flfeders/codex/release-migration-checksums into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m59s
Reviewed-on: #225
2026-09-23 14:09:57 +00:00
74c0cf40b3 fix(release): stream large migration checksums from git
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m50s
2026-09-23 15:37:19 +02:00
5ebd2d508c Merge pull request 'fix(release): exclude legacy main history from commit checks' (#224) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 5m57s
Reviewed-on: #224
2026-09-23 13:03:36 +00:00
f6996c1d7b fix(release): exclude legacy main history from commit checks
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 6m4s
2026-09-20 18:44:53 +02:00
9128fbb165 Merge pull request 'fix(ci): build dev images without repository write access' (#221) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 26s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #221
2026-09-20 16:25:38 +00:00
bb94e73229 fix(ci): build dev images without repository write access
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m54s
2026-09-20 18:22:17 +02:00
12 changed files with 377 additions and 50 deletions

View File

@@ -30,8 +30,8 @@ jobs:
with: with:
ref: ${{ github.sha }} ref: ${{ github.sha }}
fetch-depth: 0 fetch-depth: 0
# Prefer repository secrets; retain the existing variable during migration. # dev uses the normal read-only checkout; only main needs a release writer.
token: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }} token: ${{ github.ref == 'refs/heads/main' && (secrets.RELEASE_TOKEN || vars.RELEASE_TOKEN || vars.CI_TOKEN || secrets.CI_TOKEN) || github.token }}
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
@@ -41,13 +41,14 @@ jobs:
with: with:
registry: git.federspiel.tech registry: git.federspiel.tech
username: flfeders username: flfeders
password: ${{ secrets.CI_TOKEN || vars.CI_TOKEN }} # Same credential source as the existing image-build workflows.
password: ${{ vars.CI_TOKEN || secrets.CI_TOKEN }}
- name: Build all images and publish main releases - name: Build all images and publish main releases
# Gitea 1.25 ignores concurrency. ci.mjs uses an atomic Git ref lock instead. # main uses a Git ref lock. dev only reads Git and writes to the registry.
env: env:
CI: 'true' CI: 'true'
FEDEO_REF: ${{ github.ref }} FEDEO_REF: ${{ github.ref }}
FEDEO_SHA: ${{ github.sha }} FEDEO_SHA: ${{ github.sha }}
FEDEO_EVENT_NAME: ${{ github.event_name }} FEDEO_EVENT_NAME: ${{ github.event_name }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }} GITEA_TOKEN: ${{ github.ref == 'refs/heads/main' && (secrets.RELEASE_TOKEN || vars.RELEASE_TOKEN || vars.CI_TOKEN || secrets.CI_TOKEN) || '' }}
run: npm run release:ci run: npm run release:ci

View File

@@ -41,7 +41,9 @@ Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer`
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt. Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung. Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Den Ausgangscommit nach Einführung nicht ändern. Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Beide festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch.
## Versionen und Artefakte ## Versionen und Artefakte
@@ -49,8 +51,8 @@ Backend, Frontend, Dokumentation, Website und zentrale Dienste werden als gemein
- Stabile Images: `…/backend:3.2.0`, `…/backend:v3.2.0` und `…/backend:sha-<vollständiger Commit>`; entsprechend für alle sechs Komponenten. - Stabile Images: `…/backend:3.2.0`, `…/backend:v3.2.0` und `…/backend:sha-<vollständiger Commit>`; entsprechend für alle sechs Komponenten.
- Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release. - Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release.
- Entwicklung: `dev` sowie unveränderliche `dev-sha-<vollständiger Commit>`-Images. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID. - Entwicklung: `dev` sowie unveränderliche `dev-sha-<vollständiger Commit>-<UUID>`-Images. Alle Komponenten eines erfolgreichen Laufs erhalten denselben Snapshot-Tag. Auch eine Wiederholung desselben Commits baut einen eigenen Snapshot. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID.
- Jeder Build erhält zunächst einen eindeutigen temporären `build-<UUID>`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben. - Release-Builds erhalten zunächst einen eindeutigen temporären `build-<UUID>`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben.
- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh` und `SHA256SUMS`. - Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh` und `SHA256SUMS`.
Das Manifest dokumentiert die Image-Digests, den Commit und SHA-256-Prüfsummen aller enthaltenen SQL-Migrationen. Es beschreibt den ausgelieferten Migrationsstand; den tatsächlich angewendeten Stand einer Installation bestimmt deren Datenbank. Das Manifest dokumentiert die Image-Digests, den Commit und SHA-256-Prüfsummen aller enthaltenen SQL-Migrationen. Es beschreibt den ausgelieferten Migrationsstand; den tatsächlich angewendeten Stand einer Installation bestimmt deren Datenbank.
@@ -71,18 +73,22 @@ Beispiel: `{"product":"FEDEO","version":"3.2.0","revision":"<Commit-ID>"}`. Die
Der vorhandene Actions-Runner benötigt Docker mit Buildx und Netzwerkzugriff auf npm, Basis-Images und die Registry. Alle Images werden wie der bisherige Backend-Build für `linux/amd64` erzeugt. Eine zusätzliche ARM-Variante ist ein eigener Ausbau. Der vorhandene Actions-Runner benötigt Docker mit Buildx und Netzwerkzugriff auf npm, Basis-Images und die Registry. Alle Images werden wie der bisherige Backend-Build für `linux/amd64` erzeugt. Eine zusätzliche ARM-Variante ist ein eigener Ausbau.
Unter Repository → Settings → Actions → Secrets: Unter Repository → Settings → Actions → Variables bzw. Secrets:
- `CI_TOKEN`: Token des Registry-Benutzers `flfeders`, mindestens mit `write:package`. Die bisherige Variable `vars.CI_TOKEN` wird als Übergang weiterhin unterstützt; langfristig als Secret hinterlegen. - `CI_TOKEN`: Token des Registry-Benutzers `flfeders`, mindestens mit `write:package`. Die Registry-Anmeldung verwendet wie die bisherigen Build-Workflows vorrangig `vars.CI_TOKEN`; `secrets.CI_TOKEN` dient als Rückfallwert. Für eine spätere Umstellung auf ein Secret die gleichnamige Variable entfernen.
- Optional `RELEASE_TOKEN`: Token mit `write:repository` für Release-API und Git-Tags sowie zum Erzeugen/Löschen der temporären Sperr-Branches. Ohne separates Token wird `CI_TOKEN` dafür verwendet und benötigt dann zusätzlich diese Rechte. - Optional `RELEASE_TOKEN` als Secret oder Variable: Token mit `write:repository` für Release-API und Git-Tags sowie zum Erzeugen/Löschen des temporären Sperr-Branches `automation/release-lock`. Nur `main` benötigt diese Rechte. Die Reihenfolge für Git-Anmeldung und Release-API ist `secrets.RELEASE_TOKEN`, `vars.RELEASE_TOKEN`, `vars.CI_TOKEN`, `secrets.CI_TOKEN`. Ohne separates Release-Token benötigt der verwendete `CI_TOKEN` zusätzlich diese Rechte.
Der Token-Benutzer muss die entsprechenden Repository-Rechte haben. Ein Gitea-Standard-Job-Token reicht je nach Server-Konfiguration nicht zum Pushen und zum Registry-Upload. Geschützte Tags `v*` müssen den Release-Benutzer zulassen; die Branches `automation/release-lock` und `automation/dev-build-lock` müssen durch ihn erzeugt und entfernt werden können. Die Pipeline benötigt keine Schreibrechte auf den Inhalt von `main`. Auf `dev` nutzt der Checkout wie die bisherigen Workflows den normalen Job-Token zum Lesen. Entwicklungsbuilds pushen keine Git-Refs und rufen die Release-API nicht auf; der Registry-Token benötigt dafür keine Repository-Schreibrechte.
Der Release-Token-Benutzer muss die entsprechenden Repository-Rechte haben. Geschützte Tags `v*` müssen ihn zulassen; `automation/release-lock` muss durch ihn erzeugt und entfernt werden können. Die Pipeline benötigt keine Schreibrechte auf den Inhalt von `main`. Ein 403 beim Anlegen der Main-Sperre führt vor dem Image-Build zum Abbruch mit einem Hinweis auf die fehlenden Rechte.
`dev` mit der neuen Konfiguration zu pushen prüft zunächst den Entwicklungsbuild. Erst die bewusste Übernahme nach `main` erstellt den ersten stabilen Release. Release-Erstellung und Deployment haben eigene Auslöser: Server werden durch diese Pipeline nicht neu gestartet. `dev` mit der neuen Konfiguration zu pushen prüft zunächst den Entwicklungsbuild. Erst die bewusste Übernahme nach `main` erstellt den ersten stabilen Release. Release-Erstellung und Deployment haben eigene Auslöser: Server werden durch diese Pipeline nicht neu gestartet.
## Veröffentlichungsreihenfolge und Wiederaufnahme ## Veröffentlichungsreihenfolge und Wiederaufnahme
Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Runner atomar einen Git-Branch per leerem `--force-with-lease`. Ein zweiter Runner wartet bis zu zwei Stunden, ohne die Sperre zu übernehmen. Für Entwicklungsbuilds gibt es eine separate Sperre. Veraltete Main-Läufe werden vor dem Build übersprungen, wenn bereits ein neuerer Main-Commit vorhanden ist; dessen Lauf enthält alle ausstehenden Änderungen. Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Runner für Releases auf `main` atomar einen Git-Branch per leerem `--force-with-lease`. Ein zweiter Release-Runner wartet bis zu zwei Stunden, ohne die Sperre zu übernehmen. Veraltete Main-Läufe werden vor dem Build übersprungen, wenn bereits ein neuerer Main-Commit vorhanden ist; dessen Lauf enthält alle ausstehenden Änderungen.
Entwicklungsbuilds benötigen keine Git-Sperre. Parallele Läufe bauen unabhängige Snapshot-Tags. Nach dem vollständigen Build wird vor jeder Aktualisierung eines `dev`-Images geprüft, ob der Commit noch der aktuelle Stand von `origin/dev` ist. Ein erkennbar veralteter Lauf beendet die Aktualisierung. Diese Prüfung und die Registry-Aktualisierung sind nicht atomar; für einen festen Stand aller Komponenten denselben vollständigen Snapshot-Tag oder einen stabilen Release verwenden.
1. Vollständigen Commit und Tags laden, Commit-Nachrichten prüfen, Version berechnen. 1. Vollständigen Commit und Tags laden, Commit-Nachrichten prüfen, Version berechnen.
2. Alle sechs Images aus diesem Checkout mit Lockfiles bauen. Der Backend-Build führt zusätzlich seine Tests aus. Vorher laufen die Release-Tests in einem eigenen Job. 2. Alle sechs Images aus diesem Checkout mit Lockfiles bauen. Der Backend-Build führt zusätzlich seine Tests aus. Vorher laufen die Release-Tests in einem eigenen Job.
@@ -91,7 +97,7 @@ Gitea 1.25 ignoriert das Workflow-Feld `concurrency`. Deshalb reserviert der Run
5. Release veröffentlichen; Gitea erzeugt dabei den Git-Tag auf dem konkreten Commit. 5. Release veröffentlichen; Gitea erzeugt dabei den Git-Tag auf dem konkreten Commit.
6. `latest` und `main` auf die veröffentlichten Images umstellen, anschließend die Sperre entfernen. 6. `latest` und `main` auf die veröffentlichten Images umstellen, anschließend die Sperre entfernen.
Bei einem Fehler den fehlgeschlagenen Workflow **am ursprünglichen Commit erneut ausführen**. Ein vorhandenes Manifest wird weiterverwendet; veröffentlichte Versionen werden nicht neu gebaut. Auch eine unterbrochene Aktualisierung von `latest` lässt sich so vervollständigen. Die Registry bietet keine gemeinsame Transaktion über mehrere Images: deshalb verwendet Selfhosting feste Versionen und das Manifest die exakten Digests. Bei einem Release-Fehler den fehlgeschlagenen Workflow **am ursprünglichen Commit erneut ausführen**. Ein vorhandenes Manifest wird weiterverwendet; veröffentlichte Versionen werden nicht neu gebaut. Auch eine unterbrochene Aktualisierung von `latest` lässt sich so vervollständigen. Die Registry bietet keine gemeinsame Transaktion über mehrere Images: deshalb verwendet Selfhosting feste Versionen und das Manifest die exakten Digests.
Ein anderer noch offener Release-Entwurf blockiert einen neuen Release. Zuerst den ursprünglichen Lauf wiederaufnehmen; anschließend den aktuellen Main-Lauf erneut starten. Releases oder Versionstags nicht löschen, um einen fehlgeschlagenen Lauf zu überspringen. Ein anderer noch offener Release-Entwurf blockiert einen neuen Release. Zuerst den ursprünglichen Lauf wiederaufnehmen; anschließend den aktuellen Main-Lauf erneut starten. Releases oder Versionstags nicht löschen, um einen fehlgeschlagenen Lauf zu überspringen.
@@ -103,7 +109,7 @@ git ls-remote origin refs/heads/automation/release-lock
git push --force-with-lease=refs/heads/automation/release-lock:LOCK_SHA origin :refs/heads/automation/release-lock git push --force-with-lease=refs/heads/automation/release-lock:LOCK_SHA origin :refs/heads/automation/release-lock
``` ```
Für einen abgebrochenen Entwicklungsbuild entsprechend `automation/dev-build-lock` verwenden. Keine aktive Sperre löschen. Der Lease verhindert, dass zwischenzeitlich eine andere Sperre entfernt wird. Keine aktive Sperre löschen. Der Lease verhindert, dass zwischenzeitlich eine andere Sperre entfernt wird. Entwicklungsbuilds erzeugen keine Sperre und können direkt erneut gestartet werden.
## Selfhosting und Updates ## Selfhosting und Updates

View File

@@ -1,6 +1,7 @@
{ {
"initialVersion": "3.0.0", "initialVersion": "3.0.0",
"bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18", "bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18",
"legacyHistoryHeads": ["e7554fa2cc649f69dce4880abe76c6fab0f82700"],
"branch": "main", "branch": "main",
"repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO", "repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO",
"registry": "git.federspiel.tech/flfeders/fedeo", "registry": "git.federspiel.tech/flfeders/fedeo",

View File

@@ -39,16 +39,10 @@ async function promote(image, tags, immutable) {
} }
} }
async function buildImages(plan, version, reuseTag) { async function buildImages(plan, version, stagingTag = `build-${randomUUID()}`) {
const stagingTag = `build-${randomUUID()}`;
const images = []; const images = [];
for (const component of config.images) { for (const component of config.images) {
const repository = `${config.registry}/${component.name}`; 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`; const metadata = `${outputDir}/${component.name}.json`;
console.log(`Baue ${component.name} ${version} (${plan.sha.slice(0, 12)})`); console.log(`Baue ${component.name} ${version} (${plan.sha.slice(0, 12)})`);
docker(['buildx', 'build', '--push', '--platform', 'linux/amd64', '--provenance=false', docker(['buildx', 'build', '--push', '--platform', 'linux/amd64', '--provenance=false',
@@ -71,15 +65,15 @@ try {
} }
const sha = git(['rev-parse', 'HEAD']); const sha = git(['rev-parse', 'HEAD']);
if (sha !== process.env.FEDEO_SHA) throw new Error('Checkout stimmt nicht mit dem Workflow-Commit überein.'); 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}`; const stable = ref === `refs/heads/${config.branch}`;
unlock = await acquireLock(stable ? {} : { ref: 'refs/heads/automation/dev-build-lock' }); const token = process.env.GITEA_TOKEN;
if (stable && !token) throw new Error('Für main einen RELEASE_TOKEN oder CI_TOKEN mit write:repository konfigurieren (siehe docs/releases.md).');
mkdirSync(outputDir, { recursive: true });
if (stable) unlock = await acquireLock();
git(['fetch', 'origin', `refs/heads/${config.branch}:refs/remotes/origin/${config.branch}`, '--tags']); git(['fetch', 'origin', `refs/heads/${config.branch}:refs/remotes/origin/${config.branch}`, '--tags']);
if (stable) { if (stable) {
const api = new Gitea(config.repositoryUrl, token);
const releases = await api.releases(); const releases = await api.releases();
const pendingForThisCommit = releases.some(release => release.draft && stableTag.test(release.tag_name) && release.target_commitish === sha); 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) { if (git(['rev-parse', `origin/${config.branch}`]) !== sha && !pendingForThisCommit) {
@@ -96,10 +90,19 @@ try {
} }
} else { } else {
const plan = await createPlan(); const plan = await createPlan();
const images = await buildImages(plan, plan.developmentVersion, `dev-sha-${sha}`); // Separate snapshots let overlapping dev runs build without a repository write lock.
for (const image of images) await promote(image, [`dev-sha-${sha}`], true); const snapshotTag = `dev-sha-${sha}-${randomUUID()}`;
const remoteDev = git(['ls-remote', 'origin', 'refs/heads/dev']).split(/\s/)[0]; const images = await buildImages(plan, plan.developmentVersion, snapshotTag);
if (remoteDev === sha) for (const image of images) await promote(image, ['dev'], false); console.log(`Entwicklungsbuild: ${snapshotTag}`);
for (const image of images) {
// Check each update so an outdated run stops moving the mutable dev channel.
const remoteDev = git(['ls-remote', 'origin', 'refs/heads/dev']).split(/\s/)[0];
if (remoteDev !== sha) {
console.log('Ein neuerer dev-Commit liegt vor; der dev-Kanal wird nicht weiter aktualisiert.');
break;
}
await promote(image, ['dev'], false);
}
} }
} catch (error) { } catch (error) {
console.error(error.message); console.error(error.message);

View File

@@ -5,7 +5,7 @@ try {
if (command === 'check') { if (command === 'check') {
const config = readConfig(); const config = readConfig();
assertBootstrap(config, 'HEAD', process.cwd()); assertBootstrap(config, 'HEAD', process.cwd());
const commits = commitsSince(config.bootstrapCommit); const commits = commitsSince(config.bootstrapCommit, 'HEAD', process.cwd(), config.legacyHistoryHeads);
checkCommits(commits); checkCommits(commits);
console.log(`${commits.length} Commit-Nachrichten geprüft.`); console.log(`${commits.length} Commit-Nachrichten geprüft.`);
} else if (command === 'preview') { } else if (command === 'preview') {

View File

@@ -1,4 +1,4 @@
import { execFileSync } from 'node:child_process'; import { execFileSync, spawn } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
@@ -24,9 +24,9 @@ export function validCommit(message) {
(/^Revert ".+"$/.test(message.split('\n')[0]) && /^This reverts commit [a-f0-9]{40}\.$/m.test(message)); (/^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()) { export function commitsSince(from, to = 'HEAD', cwd = process.cwd(), excludedHeads = []) {
// semantic-release's revert filter expects newest commits first. // semantic-release's revert filter expects newest commits first.
const hashes = git(['rev-list', '--no-merges', `${from}..${to}`], cwd); const hashes = git(['rev-list', '--no-merges', `${from}..${to}`, ...excludedHeads.map(head => `^${head}`)], cwd);
return hashes ? hashes.split('\n').map(hash => ({ hash, message: git(['show', '-s', '--format=%B', hash], cwd) })) : []; return hashes ? hashes.split('\n').map(hash => ({ hash, message: git(['show', '-s', '--format=%B', hash], cwd) })) : [];
} }
@@ -42,6 +42,11 @@ export async function releaseType(commits, cwd = process.cwd()) {
export function assertBootstrap(config, target, cwd) { export function assertBootstrap(config, target, cwd) {
try { git(['merge-base', '--is-ancestor', config.bootstrapCommit, 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.'); } catch { throw new Error('Der Release-Ausgangscommit fehlt in der Historie. Vollständig auschecken (fetch-depth: 0) und dev nach main übernehmen.'); }
for (const head of config.legacyHistoryHeads ?? []) {
if (!/^[a-f0-9]{40}$/.test(head)) throw new Error('legacyHistoryHeads muss feste vollständige Commit-IDs enthalten.');
try { git(['rev-parse', '--verify', `${head}^{commit}`], cwd); }
catch { throw new Error(`Die alte Release-Historie ${head} fehlt. Alle Branches vollständig laden (fetch-depth: 0).`); }
}
} }
export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd), target = 'HEAD' } = {}) { export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd), target = 'HEAD' } = {}) {
@@ -53,8 +58,11 @@ export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd)
const resume = !!lastTag && git(['rev-parse', `${lastTag}^{commit}`], cwd) === sha; const resume = !!lastTag && git(['rev-parse', `${lastTag}^{commit}`], cwd) === sha;
const previousTag = resume ? tags[1] : lastTag; const previousTag = resume ? tags[1] : lastTag;
const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit; const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit;
const commits = commitsSince(from, sha, cwd); // Old main-only commits are not ancestors of the dev bootstrap. Exclude their
checkCommits(commitsSince(config.bootstrapCommit, sha, cwd)); // pinned history without dropping new work branched from those old commits.
const legacyHeads = config.legacyHistoryHeads ?? [];
const commits = commitsSince(from, sha, cwd, legacyHeads);
checkCommits(commitsSince(config.bootstrapCommit, sha, cwd, legacyHeads));
const bump = await releaseType(commits, cwd); const bump = await releaseType(commits, cwd);
const version = resume ? lastTag.slice(1) : !lastTag ? config.initialVersion : bump ? semver.inc(lastTag.slice(1), bump) : null; 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.'); if (version && !semver.valid(version)) throw new Error('Ungültige Release-Version.');
@@ -67,7 +75,7 @@ export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd)
lastRelease: previousTag ? { version: previousTag.slice(1), gitTag: previousTag, gitHead: from } : {}, lastRelease: previousTag ? { version: previousTag.slice(1), gitTag: previousTag, gitHead: from } : {},
nextRelease: { version, gitTag: tag, gitHead: sha }, nextRelease: { version, gitTag: tag, gitHead: sha },
}) : ''; }) : '';
const count = git(['rev-list', '--count', `${config.bootstrapCommit}..${sha}`], cwd); const count = git(['rev-list', '--count', `${config.bootstrapCommit}..${sha}`, ...legacyHeads.map(head => `^${head}`)], cwd);
return { return {
sha, version, tag, previousTag: previousTag || null, from, bump, resume, sha, version, tag, previousTag: previousTag || null, from, bump, resume,
developmentVersion: `${lastTag ? lastTag.slice(1) : '0.0.0'}-dev.${count}.g${sha.slice(0, 12)}`, developmentVersion: `${lastTag ? lastTag.slice(1) : '0.0.0'}-dev.${count}.g${sha.slice(0, 12)}`,
@@ -83,11 +91,33 @@ export function componentVersions(sha, cwd = process.cwd()) {
}; };
} }
export function migrationManifest(sha, cwd = process.cwd()) { function gitBlobSha256(reference, cwd) {
return ['backend/db/migrations', 'push-server/packages/db/drizzle'].flatMap(folder => { return new Promise((resolve, reject) => {
const files = git(['ls-tree', '-r', '--name-only', sha, '--', folder], cwd); const child = spawn('git', ['cat-file', 'blob', reference], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
return files ? files.split('\n').filter(path => path.endsWith('.sql')).map(path => ({ const hash = createHash('sha256');
path, sha256: createHash('sha256').update(execFileSync('git', ['show', `${sha}:${path}`], { cwd })).digest('hex'), let stderr = '';
})) : []; const fail = message => reject(new Error(`Prüfsumme für ${reference} fehlgeschlagen: ${message}`));
// Reference-data migrations can exceed 90 MB. Hash raw chunks without
// buffering the entire blob or altering whitespace/encoding.
child.stdout.on('data', chunk => hash.update(chunk));
child.stderr.setEncoding('utf8');
child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(0, 8192); });
child.once('error', error => fail(error.message));
for (const stream of [child.stdout, child.stderr]) {
stream.once('error', error => { child.kill(); fail(error.message); });
}
child.once('close', (code, signal) => {
if (code !== 0) fail(`git cat-file (Exit ${code ?? signal}): ${stderr.trim()}`);
else resolve(hash.digest('hex'));
});
}); });
} }
export async function migrationManifest(sha, cwd = process.cwd()) {
const files = git(['ls-tree', '-r', '-z', '--name-only', sha, '--',
'backend/db/migrations', 'push-server/packages/db/drizzle'], cwd)
.split('\0').filter(path => path.endsWith('.sql'));
const manifest = [];
for (const path of files) manifest.push({ path, sha256: await gitBlobSha256(`${sha}:${path}`, cwd) });
return manifest;
}

View File

@@ -15,6 +15,9 @@ export async function acquireLock({ runGit = git, wait = sleep, attempts = 480,
runGit(['push', `--force-with-lease=${ref}:`, 'origin', `${lockSha}:${ref}`]); runGit(['push', `--force-with-lease=${ref}:`, 'origin', `${lockSha}:${ref}`]);
return () => runGit(['push', `--force-with-lease=${ref}:${lockSha}`, 'origin', `:${ref}`]); return () => runGit(['push', `--force-with-lease=${ref}:${lockSha}`, 'origin', `:${ref}`]);
} catch (error) { } catch (error) {
if (/403|forbidden|permission denied|authentication failed/i.test(String(error.stderr || error.message))) {
throw new Error('Die main-Release-Sperre benötigt Git-Schreibrechte. RELEASE_TOKEN (Secret oder Variable) oder den bisherigen vars.CI_TOKEN mit write:repository und Zugriff auf automation/release-lock konfigurieren.');
}
if (!runGit(['ls-remote', 'origin', ref])) throw error; if (!runGit(['ls-remote', 'origin', ref])) throw error;
} }
} }

View File

@@ -14,11 +14,11 @@ export function assertManifest(manifest, plan, config) {
} }
} }
export function makeManifest(plan, images, config) { export async function makeManifest(plan, images, config) {
return { return {
schemaVersion: 1, version: plan.version, tag: plan.tag, commit: plan.sha, schemaVersion: 1, version: plan.version, tag: plan.tag, commit: plan.sha,
previousTag: plan.previousTag, repository: config.repositoryUrl, previousTag: plan.previousTag, repository: config.repositoryUrl,
images, components: componentVersions(plan.sha), migrations: migrationManifest(plan.sha), images, components: componentVersions(plan.sha), migrations: await migrationManifest(plan.sha),
migrationPolicy: 'Forward migrations on startup. Downgrade requires schema compatibility or a matching database backup.', migrationPolicy: 'Forward migrations on startup. Downgrade requires schema compatibility or a matching database backup.',
}; };
} }
@@ -61,7 +61,7 @@ export async function publishRelease({ plan, config, api, build, promote, files
} }
if (!manifest) { if (!manifest) {
const images = await build(plan.version); const images = await build(plan.version);
manifest = manifestFactory(plan, images, config); manifest = await manifestFactory(plan, images, config);
} }
assertManifest(manifest, plan, config); assertManifest(manifest, plan, config);
if (!release) { if (!release) {

View File

@@ -0,0 +1,136 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
const ciScript = resolve('scripts/release/ci.mjs');
function fixture(t) {
const cwd = mkdtempSync(join(tmpdir(), 'fedeo-ci-permissions-'));
t.after(() => rmSync(cwd, { recursive: true, force: true }));
const git = args => execFileSync(realGit, args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
git(['init', '-b', 'main']);
git(['config', 'user.name', 'Release Test']);
git(['config', 'user.email', 'release@example.test']);
git(['commit', '--allow-empty', '-m', 'chore: baseline']);
const baseline = git(['rev-parse', 'HEAD']);
const remote = join(cwd, 'remote.git');
git(['init', '--bare', remote]);
git(['remote', 'add', 'origin', remote]);
git(['push', 'origin', 'main']);
git(['checkout', '-b', 'dev']);
writeFileSync(join(cwd, 'release.config.json'), JSON.stringify({
initialVersion: '3.0.0', bootstrapCommit: baseline, branch: 'main',
repositoryUrl: 'https://git.example.test/owner/repo', registry: 'registry.example.test/fedeo',
images: [{ name: 'backend', context: 'backend', dockerfile: 'backend/Dockerfile' }],
}));
git(['add', 'release.config.json']);
git(['commit', '-m', 'feat: add release configuration']);
git(['push', 'origin', 'dev']);
const sha = git(['rev-parse', 'HEAD']);
const bin = join(cwd, 'bin');
mkdirSync(bin);
const gitTrace = join(cwd, 'git-calls.jsonl');
const dockerTrace = join(cwd, 'docker-calls.jsonl');
const registryFile = join(cwd, 'registry.json');
writeFileSync(gitTrace, '');
writeFileSync(dockerTrace, '');
writeFileSync(registryFile, '{}');
// All Git operations are real and local, except that the simulated CI user cannot push.
writeFileSync(join(bin, 'git'), `#!/usr/bin/env node
const { appendFileSync } = require('node:fs');
const { spawnSync } = require('node:child_process');
const args = process.argv.slice(2);
appendFileSync(process.env.TEST_GIT_TRACE, JSON.stringify(args) + '\\n');
if (args[0] === 'push') {
process.stderr.write('remote: Forbidden\\nfatal: requested URL returned error: 403\\n');
process.exit(128);
}
if (process.env.TEST_STALE_DEV === 'true' && args[0] === 'ls-remote' && args[2] === 'refs/heads/dev') {
process.stdout.write('b'.repeat(40) + '\\trefs/heads/dev\\n');
process.exit(0);
}
const result = spawnSync(process.env.TEST_REAL_GIT, args, { stdio: 'inherit' });
process.exit(result.status ?? 1);
`, { mode: 0o755 });
// Simulate only Docker's registry boundary; no external service or credential is used.
writeFileSync(join(bin, 'docker'), `#!/usr/bin/env node
const { appendFileSync, readFileSync, writeFileSync } = require('node:fs');
const { createHash } = require('node:crypto');
const args = process.argv.slice(2);
appendFileSync(process.env.TEST_DOCKER_TRACE, JSON.stringify(args) + '\\n');
const registry = JSON.parse(readFileSync(process.env.TEST_REGISTRY_FILE, 'utf8'));
if (args[1] === 'build') {
const tag = args[args.indexOf('--tag') + 1];
const digest = 'sha256:' + createHash('sha256').update(tag).digest('hex');
registry[tag] = digest;
registry[tag.slice(0, tag.lastIndexOf(':')) + '@' + digest] = digest;
writeFileSync(args[args.indexOf('--metadata-file') + 1], JSON.stringify({ 'containerimage.digest': digest }));
} else if (args[1] === 'imagetools' && args[2] === 'create') {
const digest = registry[args.at(-1)];
if (!digest) process.exit(1);
registry[args[args.indexOf('--tag') + 1]] = digest;
} else if (args[1] === 'imagetools' && args[2] === 'inspect') {
const digest = registry[args[3]];
if (!digest) { process.stderr.write('manifest unknown\\n'); process.exit(1); }
process.stdout.write(JSON.stringify({ digest }));
} else { process.exit(1); }
writeFileSync(process.env.TEST_REGISTRY_FILE, JSON.stringify(registry));
`, { mode: 0o755 });
const run = (extra = {}) => spawnSync(process.execPath, [ciScript], {
cwd, encoding: 'utf8', timeout: 30_000,
env: {
...process.env, PATH: `${bin}:${process.env.PATH}`, CI: 'true',
FEDEO_REF: 'refs/heads/dev', FEDEO_SHA: sha, FEDEO_EVENT_NAME: 'push', GITEA_TOKEN: '',
TEST_REAL_GIT: realGit, TEST_GIT_TRACE: gitTrace, TEST_DOCKER_TRACE: dockerTrace,
TEST_REGISTRY_FILE: registryFile, ...extra,
},
});
const calls = path => readFileSync(path, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
return { sha, run, gitCalls: () => calls(gitTrace), dockerCalls: () => calls(dockerTrace), registry: () => JSON.parse(readFileSync(registryFile, 'utf8')) };
}
test('dev builds and retries succeed without a release token or Git push permissions', t => {
const f = fixture(t);
for (let attempt = 0; attempt < 2; attempt++) {
const result = f.run();
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
}
assert.ok(f.gitCalls().every(args => !['push', 'commit-tree'].includes(args[0])));
const snapshots = Object.keys(f.registry()).filter(tag => tag.includes(`:dev-sha-${f.sha}-`));
assert.equal(snapshots.length, 2, 'Reruns must have separate snapshots, even for the same commit');
assert.equal(f.registry()['registry.example.test/fedeo/backend:dev'], f.registry()[snapshots[1]]);
});
test('an outdated dev run keeps its snapshot without advancing the dev channel', t => {
const f = fixture(t);
const result = f.run({ TEST_STALE_DEV: 'true' });
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
assert.equal(f.registry()['registry.example.test/fedeo/backend:dev'], undefined);
assert.match(result.stdout, /neuerer dev-Commit/);
assert.ok(f.dockerCalls().some(args => args[1] === 'build'));
});
test('main still requires a release credential before building or publishing', t => {
const f = fixture(t);
const result = f.run({ FEDEO_REF: 'refs/heads/main' });
assert.equal(result.status, 1);
assert.match(result.stderr, /RELEASE_TOKEN.*write:repository/);
assert.equal(f.dockerCalls().length, 0);
});
test('a forbidden main lock fails with an actionable message and never builds unlocked', t => {
const f = fixture(t);
const result = f.run({ FEDEO_REF: 'refs/heads/main', GITEA_TOKEN: 'synthetic-test-token' });
assert.equal(result.status, 1);
assert.match(result.stderr, /main-Release-Sperre benötigt Git-Schreibrechte/);
assert.doesNotMatch(result.stderr, /synthetic-test-token/);
assert.ok(f.gitCalls().some(args => args[0] === 'push'));
assert.equal(f.dockerCalls().length, 0);
});

View File

@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { git, migrationManifest } from '../lib.mjs';
function fixture(t) {
const cwd = mkdtempSync(join(tmpdir(), 'fedeo-migrations-test-'));
t.after(() => rmSync(cwd, { recursive: true, force: true }));
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']);
// Keep CRLF test bytes in Git regardless of the developer's autocrlf setting.
writeFileSync(join(cwd, '.gitattributes'), '*.sql -text\n');
const write = (path, data) => {
mkdirSync(dirname(join(cwd, path)), { recursive: true });
writeFileSync(join(cwd, path), data);
};
const commit = () => {
run(['add', '.']);
run(['commit', '--allow-empty', '-m', 'test: add migrations']);
return run(['rev-parse', 'HEAD']);
};
return { cwd, run, write, commit };
}
test('large migration hashes use the complete committed bytes beyond process buffer limits', async t => {
const f = fixture(t);
const path = 'backend/db/migrations/0058_reference_data.sql';
f.write(path, '');
const expected = createHash('sha256');
const chunk = Buffer.alloc(1024 * 1024, Buffer.from(" INSERT INTO reference VALUES ('Grüße');\r\n"));
// Exceed both Node's 1 MiB default and the text Git helper's 32 MiB buffer.
for (let i = 0; i < 34; i++) {
appendFileSync(join(f.cwd, path), chunk);
expected.update(chunk);
}
const sha = f.commit();
f.write(path, 'local changes must not affect the release checksum');
assert.deepEqual(await migrationManifest(sha, f.cwd), [{ path, sha256: expected.digest('hex') }]);
});
test('both migration folders are hashed byte-for-byte and non-SQL files are excluded', async t => {
const f = fixture(t);
const files = [
['backend/db/migrations/0000_init.sql', Buffer.from(' \r\nSELECT 1;\r\n\t')],
['push-server/packages/db/drizzle/0000_init.sql', Buffer.from('SELECT \'äöü\';\n')],
];
for (const [path, content] of files) f.write(path, content);
f.write('backend/db/migrations/meta/snapshot.json', '{}');
const sha = f.commit();
assert.deepEqual(await migrationManifest(sha, f.cwd), files.map(([path, content]) => ({
path, sha256: createHash('sha256').update(content).digest('hex'),
})));
});
test('a commit without migration folders produces an empty manifest', async t => {
const f = fixture(t);
assert.deepEqual(await migrationManifest(f.commit(), f.cwd), []);
});
test('an unreadable Git blob rejects the manifest instead of recording a partial checksum', async t => {
const f = fixture(t);
const path = 'backend/db/migrations/0000_missing.sql';
f.write(path, 'SELECT 1;');
const sha = f.commit();
const blob = f.run(['rev-parse', `${sha}:${path}`]);
rmSync(join(f.cwd, '.git', 'objects', blob.slice(0, 2), blob.slice(2)));
await assert.rejects(async () => migrationManifest(sha, f.cwd), /0000_missing\.sql/);
});

View File

@@ -1,5 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
@@ -17,9 +18,9 @@ function fixture(t) {
run(['commit', '--allow-empty', '-m', 'historic baseline']); 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' }; const config = { bootstrapCommit: run(['rev-parse', 'HEAD']), initialVersion: '1.0.0', repositoryUrl: 'https://git.example.test/owner/fedeo' };
let count = 0; let count = 0;
const commit = message => { const commit = (message, file = 'file') => {
writeFileSync(join(cwd, 'file'), `${++count}\n`); writeFileSync(join(cwd, file), `${++count}\n`);
run(['add', 'file']); run(['add', file]);
run(['commit', '-m', message]); run(['commit', '-m', message]);
return run(['rev-parse', 'HEAD']); return run(['rev-parse', 'HEAD']);
}; };
@@ -52,6 +53,51 @@ test('first release uses explicit baseline and excludes legacy history', async t
assert.doesNotMatch(plan.notes, /historic baseline|compare\/\.\.\./); assert.doesNotMatch(plan.notes, /historic baseline|compare\/\.\.\./);
}); });
function divergentHistory(t) {
const f = fixture(t);
f.run(['branch', 'legacy-main']);
f.config.bootstrapCommit = f.commit('chore: versioning baseline');
f.commit('feat: current export');
f.run(['checkout', 'legacy-main']);
f.commit('.gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert', 'legacy-template');
const legacyHead = f.commit('feat: historic main feature', 'legacy-feature');
f.config.legacyHistoryHeads = [legacyHead];
return f;
}
test('the first main merge excludes pinned legacy branch history from validation and notes', async t => {
const f = divergentHistory(t);
f.run(['checkout', 'main']);
f.run(['merge', '--no-ff', 'legacy-main', '-m', 'Merge historic main']);
const plan = await f.plan();
assert.equal(plan.version, '1.0.0');
assert.match(plan.notes, /current export/);
assert.doesNotMatch(plan.notes, /historic main feature|ISSUE_TEMPLATE/);
writeFileSync(join(f.cwd, 'release.config.json'), JSON.stringify(f.config));
const output = execFileSync(process.execPath, [resolve('scripts/release/cli.mjs'), 'check'], { cwd: f.cwd, encoding: 'utf8' });
assert.match(output, /1 Commit-Nachrichten geprüft/);
});
test('new commits on a legacy branch still affect versions and must use Conventional Commits', async t => {
const f = divergentHistory(t);
f.run(['checkout', 'main']);
f.run(['tag', 'v1.0.0']);
f.run(['checkout', 'legacy-main']);
f.commit('feat(api)!: replace exported API', 'new-api');
f.run(['checkout', 'main']);
f.run(['merge', '--no-ff', 'legacy-main', '-m', 'Merge new API']);
const plan = await f.plan();
assert.equal(plan.version, '2.0.0');
assert.match(plan.notes, /replace exported API/);
assert.doesNotMatch(plan.notes, /historic main feature/);
f.run(['checkout', 'legacy-main']);
f.commit('Unclassified new change', 'another-change');
f.run(['checkout', 'main']);
f.run(['merge', '--no-ff', 'legacy-main', '-m', 'Merge another change']);
await assert.rejects(f.plan(), /Conventional Commits fehlen:[\s\S]*Unclassified new change/);
});
test('patch/minor/major releases, multi-digit tags and unrelated component tags', async t => { test('patch/minor/major releases, multi-digit tags and unrelated component tags', async t => {
const f = fixture(t); const f = fixture(t);
f.run(['tag', 'v1.9.0']); f.run(['tag', 'v1.9.0']);
@@ -104,6 +150,14 @@ test('an unknown baseline fails instead of releasing unrelated history', async t
await assert.rejects(f.plan(), /Ausgangscommit/); await assert.rejects(f.plan(), /Ausgangscommit/);
}); });
test('missing or mutable legacy history heads fail instead of silently skipping validation', async t => {
const f = fixture(t);
f.config.legacyHistoryHeads = ['f'.repeat(40)];
await assert.rejects(f.plan(), /alte Release-Historie .* fehlt/);
f.config.legacyHistoryHeads = ['main'];
await assert.rejects(f.plan(), /feste vollständige Commit-IDs/);
});
test('Git lease lock excludes concurrent runners and cannot remove another owner', async t => { test('Git lease lock excludes concurrent runners and cannot remove another owner', async t => {
const f = fixture(t); const f = fixture(t);
const remote = join(f.cwd, 'remote.git'); const remote = join(f.cwd, 'remote.git');

View File

@@ -58,6 +58,26 @@ test('failed build never creates a draft or changes release channels', async ()
assert.deepEqual(f.events, []); assert.deepEqual(f.events, []);
}); });
test('asynchronous manifest generation finishes before creating the release draft', async () => {
const f = fixture();
f.dependencies.manifestFactory = async () => {
await new Promise(resolve => setImmediate(resolve));
f.events.push('manifest');
return f.manifest;
};
await publishRelease(f.dependencies);
assert.deepEqual(f.events.slice(0, 3), ['build', 'manifest', 'draft']);
assert.equal(f.getRelease().draft, false);
});
test('failed migration hashing never creates a draft or promotes images', async () => {
const f = fixture();
f.dependencies.manifestFactory = async () => { throw new Error('migration hashing failed'); };
await assert.rejects(publishRelease(f.dependencies), /migration hashing failed/);
assert.deepEqual(f.events, ['build']);
assert.equal(f.getRelease(), null);
});
test('retry a draft uses saved digests and original notes without rebuilding', async () => { test('retry a draft uses saved digests and original notes without rebuilding', async () => {
const f = fixture(); const f = fixture();
f.dependencies.promote = async () => { throw new Error('registry interrupted'); }; f.dependencies.promote = async () => { throw new Error('registry interrupted'); };