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, stagingTag = `build-${randomUUID()}`) { const images = []; for (const component of config.images) { const repository = `${config.registry}/${component.name}`; 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 stable = ref === `refs/heads/${config.branch}`; 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) { 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(); // 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); process.exitCode = 1; } finally { if (unlock) { try { unlock(); } catch (error) { console.error(`Release-Sperre konnte nicht entfernt werden: ${error.message}`); process.exitCode = 1; } } }