fix(release): exclude legacy main history from commit checks
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 6m4s

This commit is contained in:
2026-09-20 18:44:53 +02:00
parent bb94e73229
commit f6996c1d7b
5 changed files with 75 additions and 10 deletions

View File

@@ -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. 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 ## Versionen und Artefakte

View File

@@ -1,6 +1,7 @@
{ {
"initialVersion": "3.0.0", "initialVersion": "3.0.0",
"bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18", "bootstrapCommit": "cfef07b96d97f8dffdfc32c74f8f32b62fe43b18",
"legacyHistoryHeads": ["e7554fa2cc649f69dce4880abe76c6fab0f82700"],
"branch": "main", "branch": "main",
"repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO", "repositoryUrl": "https://git.federspiel.tech/flfeders/FEDEO",
"registry": "git.federspiel.tech/flfeders/fedeo", "registry": "git.federspiel.tech/flfeders/fedeo",

View File

@@ -5,7 +5,7 @@ try {
if (command === 'check') { if (command === 'check') {
const config = readConfig(); const config = readConfig();
assertBootstrap(config, 'HEAD', process.cwd()); assertBootstrap(config, 'HEAD', process.cwd());
const commits = commitsSince(config.bootstrapCommit); const commits = commitsSince(config.bootstrapCommit, 'HEAD', process.cwd(), config.legacyHistoryHeads);
checkCommits(commits); checkCommits(commits);
console.log(`${commits.length} Commit-Nachrichten geprüft.`); console.log(`${commits.length} Commit-Nachrichten geprüft.`);
} else if (command === 'preview') { } else if (command === 'preview') {

View File

@@ -24,9 +24,9 @@ export function validCommit(message) {
(/^Revert ".+"$/.test(message.split('\n')[0]) && /^This reverts commit [a-f0-9]{40}\.$/m.test(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. // 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) })) : []; 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) { export function assertBootstrap(config, target, cwd) {
try { git(['merge-base', '--is-ancestor', config.bootstrapCommit, 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.'); } 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' } = {}) { 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 resume = !!lastTag && git(['rev-parse', `${lastTag}^{commit}`], cwd) === sha;
const previousTag = resume ? tags[1] : lastTag; const previousTag = resume ? tags[1] : lastTag;
const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit; const from = previousTag ? git(['rev-parse', `${previousTag}^{commit}`], cwd) : config.bootstrapCommit;
const commits = commitsSince(from, sha, cwd); // Old main-only commits are not ancestors of the dev bootstrap. Exclude their
checkCommits(commitsSince(config.bootstrapCommit, sha, cwd)); // 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 bump = await releaseType(commits, cwd);
const version = resume ? lastTag.slice(1) : !lastTag ? config.initialVersion : bump ? semver.inc(lastTag.slice(1), bump) : null; 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.'); 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 } : {}, lastRelease: previousTag ? { version: previousTag.slice(1), gitTag: previousTag, gitHead: from } : {},
nextRelease: { version, gitTag: tag, gitHead: sha }, 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 { return {
sha, version, tag, previousTag: previousTag || null, from, bump, resume, sha, version, tag, previousTag: previousTag || null, from, bump, resume,
developmentVersion: `${lastTag ? lastTag.slice(1) : '0.0.0'}-dev.${count}.g${sha.slice(0, 12)}`, developmentVersion: `${lastTag ? lastTag.slice(1) : '0.0.0'}-dev.${count}.g${sha.slice(0, 12)}`,

View File

@@ -1,5 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
@@ -17,9 +18,9 @@ function fixture(t) {
run(['commit', '--allow-empty', '-m', 'historic baseline']); 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' }; const config = { bootstrapCommit: run(['rev-parse', 'HEAD']), initialVersion: '1.0.0', repositoryUrl: 'https://git.example.test/owner/fedeo' };
let count = 0; let count = 0;
const commit = message => { const commit = (message, file = 'file') => {
writeFileSync(join(cwd, 'file'), `${++count}\n`); writeFileSync(join(cwd, file), `${++count}\n`);
run(['add', 'file']); run(['add', file]);
run(['commit', '-m', message]); run(['commit', '-m', message]);
return run(['rev-parse', 'HEAD']); 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\/\.\.\./); 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 => { test('patch/minor/major releases, multi-digit tags and unrelated component tags', async t => {
const f = fixture(t); const f = fixture(t);
f.run(['tag', 'v1.9.0']); 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/); 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 => { test('Git lease lock excludes concurrent runners and cannot remove another owner', async t => {
const f = fixture(t); const f = fixture(t);
const remote = join(f.cwd, 'remote.git'); const remote = join(f.cwd, 'remote.git');