From f6996c1d7b419e356f3e299967af380d8d9ac436 Mon Sep 17 00:00:00 2001 From: flfeders Date: Sun, 20 Sep 2026 18:44:53 +0200 Subject: [PATCH] fix(release): exclude legacy main history from commit checks --- docs/releases.md | 4 +- release.config.json | 1 + scripts/release/cli.mjs | 2 +- scripts/release/lib.mjs | 18 +++++--- scripts/release/tests/planning.test.mjs | 60 +++++++++++++++++++++++-- 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 3224af67..5865bc4a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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. -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 diff --git a/release.config.json b/release.config.json index bdea7c24..8b4a92cc 100644 --- a/release.config.json +++ b/release.config.json @@ -1,6 +1,7 @@ { "initialVersion": "3.0.0", "bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18", + "legacyHistoryHeads": ["e7554fa2cc649f69dce4880abe76c6fab0f82700"], "branch": "main", "repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO", "registry": "git.federspiel.tech/flfeders/fedeo", diff --git a/scripts/release/cli.mjs b/scripts/release/cli.mjs index 7ff59492..48acc6d2 100644 --- a/scripts/release/cli.mjs +++ b/scripts/release/cli.mjs @@ -5,7 +5,7 @@ try { if (command === 'check') { const config = readConfig(); assertBootstrap(config, 'HEAD', process.cwd()); - const commits = commitsSince(config.bootstrapCommit); + const commits = commitsSince(config.bootstrapCommit, 'HEAD', process.cwd(), config.legacyHistoryHeads); checkCommits(commits); console.log(`${commits.length} Commit-Nachrichten geprüft.`); } else if (command === 'preview') { diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs index 4462c622..202bdc6b 100644 --- a/scripts/release/lib.mjs +++ b/scripts/release/lib.mjs @@ -24,9 +24,9 @@ export function validCommit(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. - 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) })) : []; } @@ -42,6 +42,11 @@ export async function releaseType(commits, cwd = process.cwd()) { 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' } = {}) { @@ -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 previousTag = resume ? tags[1] : lastTag; const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit; - const commits = commitsSince(from, sha, cwd); - checkCommits(commitsSince(config.bootstrapCommit, sha, cwd)); + // 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.'); @@ -67,7 +75,7 @@ export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd) 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}`], cwd); + 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)}`, diff --git a/scripts/release/tests/planning.test.mjs b/scripts/release/tests/planning.test.mjs index 95d63a15..4351a606 100644 --- a/scripts/release/tests/planning.test.mjs +++ b/scripts/release/tests/planning.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -17,9 +18,9 @@ function fixture(t) { 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' }; let count = 0; - const commit = message => { - writeFileSync(join(cwd, 'file'), `${++count}\n`); - run(['add', 'file']); + const commit = (message, file = 'file') => { + writeFileSync(join(cwd, file), `${++count}\n`); + run(['add', file]); run(['commit', '-m', message]); 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\/\.\.\./); }); +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 => { const f = fixture(t); 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/); }); +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 => { const f = fixture(t); const remote = join(f.cwd, 'remote.git'); -- 2.49.1