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'; import { checkCommits, createPlan, git, releaseType, validCommit } from '../lib.mjs'; import { acquireLock, lockRef } from '../lock.mjs'; function fixture(t) { const cwd = mkdtempSync(join(tmpdir(), 'fedeo-release-test-')); t.after(() => rmSync(cwd, { recursive: true, force: true })); symlinkSync(resolve('node_modules'), join(cwd, 'node_modules'), 'dir'); const run = args => git(args, cwd, { stdio: ['pipe', 'pipe', 'pipe'] }); run(['init', '-b', 'main']); run(['config', 'user.name', 'Release Test']); run(['config', 'user.email', 'release@example.test']); 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, file = 'file') => { writeFileSync(join(cwd, file), `${++count}\n`); run(['add', file]); run(['commit', '-m', message]); return run(['rev-parse', 'HEAD']); }; return { cwd, config, commit, run, plan: () => createPlan({ cwd, config }) }; } test('commit types, scopes and breaking headers are enforced', () => { for (const message of ['fix: repair', 'feat(stock): add transfers', 'feat(api)!: replace auth', 'ci: build', 'revert: restore']) assert.ok(validCommit(message)); for (const message of ['changes', 'Feat: uppercase', 'fix:', 'fix(x) missing colon', 'fix: ']) assert.equal(validCommit(message), false); assert.throws(() => checkCommits([{ hash: 'abc123', message: 'misc changes' }]), /Conventional Commits/); }); test('semantic-release determines the highest required bump and handles maintenance', async () => { const commit = message => ({ hash: 'a'.repeat(40), message }); assert.equal(await releaseType([commit('fix: repair')]), 'patch'); assert.equal(await releaseType([commit('perf: reduce latency')]), 'patch'); assert.equal(await releaseType([commit('fix: repair'), commit('feat: add export')]), 'minor'); assert.equal(await releaseType([commit('feat(api)!: replace auth')]), 'major'); assert.equal(await releaseType([commit('refactor: change API\n\nBREAKING CHANGE: remove old API')]), 'major'); assert.equal(await releaseType([commit('docs: improve guide'), commit('ci: check build')]), null); }); test('first release uses explicit baseline and excludes legacy history', async t => { const f = fixture(t); f.commit('feat: export'); const plan = await f.plan(); assert.equal(plan.version, '1.0.0'); assert.equal(plan.tag, 'v1.0.0'); assert.match(plan.notes, /export/); 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']); f.run(['tag', 'v1.10.0']); f.run(['tag', 'mobile-v99.0.0']); f.commit('fix: repair'); assert.equal((await f.plan()).version, '1.10.1'); f.commit('feat: add transfer'); assert.equal((await f.plan()).version, '1.11.0'); f.commit('fix(api)!: remove field'); assert.equal((await f.plan()).version, '2.0.0'); }); test('maintenance does not release; same tagged commit resumes the same version', async t => { const f = fixture(t); f.run(['tag', 'v1.0.0']); f.commit('docs: guide'); assert.equal((await f.plan()).version, null); f.commit('feat: export'); f.run(['tag', 'v1.1.0']); const plan = await f.plan(); assert.equal(plan.version, '1.1.0'); assert.equal(plan.resume, true); assert.equal(plan.previousTag, 'v1.0.0'); assert.match(plan.notes, /export/); }); test('reverted changes cancel out within the same release range', async t => { const f = fixture(t); f.run(['tag', 'v1.0.0']); const sha = f.commit('feat: temporary feature'); f.run(['revert', '--no-edit', sha]); assert.equal((await f.plan()).version, null); }); test('real merge commits do not replace the feature commits in release notes', async t => { const f = fixture(t); f.run(['checkout', '-b', 'feature']); f.commit('feat: new feature'); f.run(['checkout', 'main']); f.run(['merge', '--no-ff', 'feature', '-m', 'Merge feature']); const plan = await f.plan(); assert.match(plan.notes, /new feature/); assert.doesNotMatch(plan.notes, /Merge feature/); }); test('an unknown baseline fails instead of releasing unrelated history', async t => { const f = fixture(t); f.config.bootstrapCommit = 'f'.repeat(40); 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'); f.run(['init', '--bare', remote]); f.run(['remote', 'add', 'origin', remote]); const unlock = await acquireLock({ runGit: f.run, wait: async () => {}, attempts: 1 }); await assert.rejects(acquireLock({ runGit: f.run, wait: async () => {}, attempts: 1 }), /belegt/); const replacement = f.commit('chore: other lock owner'); f.run(['push', '--force', 'origin', `${replacement}:${lockRef}`]); assert.throws(unlock); assert.match(f.run(['ls-remote', 'origin', lockRef]), new RegExp(replacement)); }); test('Git lease lock is released after a successful run', async t => { const f = fixture(t); const remote = join(f.cwd, 'remote.git'); f.run(['init', '--bare', remote]); f.run(['remote', 'add', 'origin', remote]); const unlock = await acquireLock({ runGit: f.run, attempts: 1 }); unlock(); assert.equal(f.run(['ls-remote', 'origin', lockRef]), ''); });