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

This commit is contained in:
2026-09-20 18:22:17 +02:00
parent 68c0a224e5
commit bb94e73229
5 changed files with 177 additions and 30 deletions

View File

@@ -30,8 +30,8 @@ jobs:
with:
ref: ${{ github.sha }}
fetch-depth: 0
# Prefer repository secrets; retain the existing variable during migration.
token: ${{ secrets.RELEASE_TOKEN || secrets.CI_TOKEN || vars.CI_TOKEN }}
# dev uses the normal read-only checkout; only main needs a release writer.
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
with:
node-version: '22'
@@ -41,13 +41,14 @@ jobs:
with:
registry: git.federspiel.tech
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
# 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:
CI: 'true'
FEDEO_REF: ${{ github.ref }}
FEDEO_SHA: ${{ github.sha }}
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

View File

@@ -49,8 +49,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.
- 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.
- 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.
- 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.
- 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`.
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 +71,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.
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.
- 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.
- `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` 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.
## 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.
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 +95,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.
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.
@@ -103,7 +107,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
```
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

View File

@@ -39,16 +39,10 @@ async function promote(image, tags, immutable) {
}
}
async function buildImages(plan, version, reuseTag) {
const stagingTag = `build-${randomUUID()}`;
async function buildImages(plan, version, 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',
@@ -71,15 +65,15 @@ try {
}
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' });
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']);
if (stable) {
const api = new Gitea(config.repositoryUrl, token);
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) {
@@ -96,10 +90,19 @@ try {
}
} 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);
// Separate snapshots let overlapping dev runs build without a repository write lock.
const snapshotTag = `dev-sha-${sha}-${randomUUID()}`;
const images = await buildImages(plan, plan.developmentVersion, snapshotTag);
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) {
console.error(error.message);

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}`]);
return () => runGit(['push', `--force-with-lease=${ref}:${lockSha}`, 'origin', `:${ref}`]);
} 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;
}
}

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