import { execFileSync, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { analyzeCommits } from '@semantic-release/commit-analyzer'; import { generateNotes } from '@semantic-release/release-notes-generator'; import semver from 'semver'; export const logger = { log() {} }; export const stableTag = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; const commitHeader = /^(feat|fix|perf|docs|test|refactor|build|ci|chore|style|revert)(\([^()\r\n]+\))?!?: \S.*$/; export function git(args, cwd = process.cwd(), options = {}) { return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...options }).trim(); } export function readConfig(cwd = process.cwd()) { return JSON.parse(readFileSync(resolve(cwd, 'release.config.json'), 'utf8')); } export function validCommit(message) { // Git's generated revert message is understood by the Conventional Commits preset. return commitHeader.test(message.split('\n')[0]) || (/^Revert ".+"$/.test(message.split('\n')[0]) && /^This reverts commit [a-f0-9]{40}\.$/m.test(message)); } export function commitsSince(from, to = 'HEAD', cwd = process.cwd(), excludedHeads = []) { // semantic-release's revert filter expects newest commits first. 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) })) : []; } export function checkCommits(commits) { const invalid = commits.filter(commit => !validCommit(commit.message)); if (invalid.length) throw new Error(`Conventional Commits fehlen:\n${invalid.map(c => `${c.hash.slice(0, 8)} ${c.message.split('\n')[0]}`).join('\n')}\nVerwende z. B. fix(scope): Beschreibung oder feat(scope): Beschreibung.`); } export async function releaseType(commits, cwd = process.cwd()) { return analyzeCommits({ preset: 'conventionalcommits' }, { cwd, commits, logger }); } export function assertBootstrap(config, target, cwd) { try { git(['merge-base', '--is-ancestor', config.bootstrapCommit, target], cwd); } catch { throw new Error('Der Release-Ausgangscommit fehlt in der Historie. Vollständig auschecken (fetch-depth: 0) und dev nach main übernehmen.'); } 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' } = {}) { const sha = git(['rev-parse', `${target}^{commit}`], cwd); assertBootstrap(config, sha, cwd); const tags = git(['tag', '--merged', sha], cwd).split('\n').filter(tag => stableTag.test(tag)) .sort((a, b) => semver.rcompare(a.slice(1), b.slice(1))); const lastTag = tags[0]; const resume = !!lastTag && git(['rev-parse', `${lastTag}^{commit}`], cwd) === sha; const previousTag = resume ? tags[1] : lastTag; const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit; // Old main-only commits are not ancestors of the dev bootstrap. Exclude their // 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 version = resume ? lastTag.slice(1) : !lastTag ? config.initialVersion : bump ? semver.inc(lastTag.slice(1), bump) : null; if (version && !semver.valid(version)) throw new Error('Ungültige Release-Version.'); const tag = version ? `v${version}` : null; const notes = version ? await generateNotes({ preset: 'conventionalcommits', host: new URL(config.repositoryUrl).origin, commit: 'commit', issue: 'issues', linkCompare: Boolean(previousTag), }, { cwd, logger, commits, options: { repositoryUrl: config.repositoryUrl }, lastRelease: previousTag ? { version: previousTag.slice(1), gitTag: previousTag, gitHead: from } : {}, nextRelease: { version, gitTag: tag, gitHead: sha }, }) : ''; const count = git(['rev-list', '--count', `${config.bootstrapCommit}..${sha}`, ...legacyHeads.map(head => `^${head}`)], cwd); return { sha, version, tag, previousTag: previousTag || null, from, bump, resume, developmentVersion: `${lastTag ? lastTag.slice(1) : '0.0.0'}-dev.${count}.g${sha.slice(0, 12)}`, notes: version ? `${!previousTag ? 'Erster versionierter FEDEO-Release. Die ältere Entwicklungshistorie bleibt im Git-Repository erhalten.\n\n' : ''}${notes}` : '', }; } export function componentVersions(sha, cwd = process.cwd()) { const json = path => JSON.parse(git(['show', `${sha}:${path}`], cwd)); return { mobile: { version: json('mobile/app.json').expo.version, iosBuildNumber: json('mobile/app.json').expo.ios.buildNumber }, deviceAgent: { version: json('agents/fedeo-device-agent/package.json').version }, }; } function gitBlobSha256(reference, cwd) { return new Promise((resolve, reject) => { const child = spawn('git', ['cat-file', 'blob', reference], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); const hash = createHash('sha256'); 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; }