diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index 9ea24607..994882e5 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -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 diff --git a/docs/releases.md b/docs/releases.md index 17763685..3224af67 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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-`; 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-`-Images. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID. -- Jeder Build erhält zunächst einen eindeutigen temporären `build-`-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--`-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-`-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":""}`. 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 diff --git a/scripts/release/ci.mjs b/scripts/release/ci.mjs index c6f422a3..077e865e 100644 --- a/scripts/release/ci.mjs +++ b/scripts/release/ci.mjs @@ -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); diff --git a/scripts/release/lock.mjs b/scripts/release/lock.mjs index ca929c7b..bfb246a8 100644 --- a/scripts/release/lock.mjs +++ b/scripts/release/lock.mjs @@ -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; } } diff --git a/scripts/release/tests/ci-permissions.test.mjs b/scripts/release/tests/ci-permissions.test.mjs new file mode 100644 index 00000000..4833138a --- /dev/null +++ b/scripts/release/tests/ci-permissions.test.mjs @@ -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); +});