fix(release): exclude legacy main history from commit checks #224

Merged
flfeders merged 1 commits from dev into main 2026-09-23 13:03:37 +00:00
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.
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

View File

@@ -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",

View File

@@ -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') {

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));
}
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)}`,

View File

@@ -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');