feat(release): automate versioned releases on main

This commit is contained in:
2026-09-17 18:41:53 +02:00
parent dd37622bb6
commit 92859f3ccf
39 changed files with 7365 additions and 243 deletions

View File

@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
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 => {
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\/\.\.\./);
});
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('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]), '');
});