feat(release): automate versioned releases on main
This commit is contained in:
128
scripts/release/tests/planning.test.mjs
Normal file
128
scripts/release/tests/planning.test.mjs
Normal 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]), '');
|
||||
});
|
||||
166
scripts/release/tests/publication.test.mjs
Normal file
166
scripts/release/tests/publication.test.mjs
Normal file
@@ -0,0 +1,166 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { publishRelease, assertManifest, releaseFiles } from '../publish.mjs';
|
||||
import { Gitea } from '../gitea.mjs';
|
||||
|
||||
function fixture() {
|
||||
const plan = { version: '1.2.0', tag: 'v1.2.0', sha: 'a'.repeat(40), notes: 'Release notes' };
|
||||
const config = { registry: 'registry.test/fedeo', images: [{ name: 'backend' }, { name: 'frontend' }] };
|
||||
const images = config.images.map((item, i) => ({ ...item, repository: `${config.registry}/${item.name}`, digest: `sha256:${String(i).repeat(64)}` }));
|
||||
const manifest = { version: plan.version, tag: plan.tag, commit: plan.sha, images };
|
||||
const events = [];
|
||||
let release = null;
|
||||
let tag = null;
|
||||
const assets = new Map();
|
||||
const api = {
|
||||
async releases() { return release ? [release] : []; },
|
||||
async release() { return release; },
|
||||
async tag() { return tag; },
|
||||
async asset(_release, name) { return assets.get(name); },
|
||||
async upload(_release, name, bytes) {
|
||||
events.push(`upload:${name}`);
|
||||
if (assets.has(name)) assert.deepEqual(assets.get(name), bytes);
|
||||
assets.set(name, bytes);
|
||||
},
|
||||
async request(_path, { method, body }) {
|
||||
if (method === 'POST') {
|
||||
events.push('draft');
|
||||
// Gitea drafts deliberately do NOT create a tag.
|
||||
release = { ...body, id: 1 };
|
||||
} else {
|
||||
events.push('publish');
|
||||
release = { ...release, ...body };
|
||||
tag = { commit: { sha: plan.sha } };
|
||||
}
|
||||
return release;
|
||||
},
|
||||
};
|
||||
const dependencies = {
|
||||
plan, config, api,
|
||||
build: async () => { events.push('build'); return images; },
|
||||
promote: async (image, _tags, immutable) => { events.push(`${immutable ? 'version' : 'channel'}:${image.name}`); },
|
||||
manifestFactory: () => manifest,
|
||||
files: (saved, notes) => new Map([['release-manifest.json', Buffer.from(JSON.stringify(saved))], ['CHANGELOG.md', Buffer.from(notes)]]),
|
||||
};
|
||||
return { plan, config, manifest, api, events, assets, dependencies, getRelease: () => release, setTag: value => { tag = value; } };
|
||||
}
|
||||
|
||||
test('publish only after builds, assets and immutable images; channels move last', async () => {
|
||||
const f = fixture();
|
||||
await publishRelease(f.dependencies);
|
||||
assert.deepEqual(f.events, ['build', 'draft', 'upload:release-manifest.json', 'upload:CHANGELOG.md', 'version:backend', 'version:frontend', 'publish', 'channel:backend', 'channel:frontend']);
|
||||
});
|
||||
|
||||
test('failed build never creates a draft or changes release channels', async () => {
|
||||
const f = fixture();
|
||||
f.dependencies.build = async () => { throw new Error('build failed'); };
|
||||
await assert.rejects(publishRelease(f.dependencies), /build failed/);
|
||||
assert.deepEqual(f.events, []);
|
||||
});
|
||||
|
||||
test('retry a draft uses saved digests and original notes without rebuilding', async () => {
|
||||
const f = fixture();
|
||||
f.dependencies.promote = async () => { throw new Error('registry interrupted'); };
|
||||
await assert.rejects(publishRelease(f.dependencies), /registry interrupted/);
|
||||
assert.equal(f.getRelease().draft, true);
|
||||
assert.equal(await f.api.tag(), null);
|
||||
f.plan.notes = 'Notes generated on another day';
|
||||
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
|
||||
f.dependencies.promote = async () => {};
|
||||
await publishRelease(f.dependencies);
|
||||
assert.equal(f.getRelease().draft, false);
|
||||
assert.equal(f.assets.get('CHANGELOG.md').toString(), 'Release notes');
|
||||
});
|
||||
|
||||
test('retry after publication only repairs mutable channels', async () => {
|
||||
const f = fixture();
|
||||
const promote = f.dependencies.promote;
|
||||
f.dependencies.promote = async (image, tags, immutable) => {
|
||||
if (!immutable) throw new Error('channel update interrupted');
|
||||
await promote(image, tags, immutable);
|
||||
};
|
||||
await assert.rejects(publishRelease(f.dependencies), /channel update interrupted/);
|
||||
f.events.length = 0;
|
||||
f.dependencies.promote = promote;
|
||||
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
|
||||
await publishRelease(f.dependencies);
|
||||
assert.deepEqual(f.events, ['channel:backend', 'channel:frontend']);
|
||||
});
|
||||
|
||||
test('partial asset upload remains a draft and retries safely', async () => {
|
||||
const f = fixture();
|
||||
const upload = f.api.upload;
|
||||
f.api.upload = async (...args) => {
|
||||
if (args[1] === 'CHANGELOG.md') throw new Error('upload interrupted');
|
||||
return upload(...args);
|
||||
};
|
||||
await assert.rejects(publishRelease(f.dependencies), /upload interrupted/);
|
||||
assert.equal(f.getRelease().draft, true);
|
||||
assert.ok(!f.events.includes('publish'));
|
||||
f.api.upload = upload;
|
||||
f.dependencies.build = async () => { throw new Error('must not rebuild'); };
|
||||
await publishRelease(f.dependencies);
|
||||
});
|
||||
|
||||
test('another pending release must be completed first', async () => {
|
||||
const f = fixture();
|
||||
f.api.releases = async () => [{ draft: true, tag_name: 'v1.1.0' }];
|
||||
await assert.rejects(publishRelease(f.dependencies), /unvollständigen Release/);
|
||||
assert.deepEqual(f.events, []);
|
||||
});
|
||||
|
||||
test('a historical rerun cannot roll latest back', async () => {
|
||||
const f = fixture();
|
||||
f.api.releases = async () => [{ draft: false, tag_name: 'v2.0.0' }];
|
||||
await assert.rejects(publishRelease(f.dependencies), /Downgrade/);
|
||||
});
|
||||
|
||||
test('tag collisions and manifests with wrong digests or commits are rejected', async () => {
|
||||
const f = fixture();
|
||||
f.setTag({ commit: { sha: 'b'.repeat(40) } });
|
||||
await assert.rejects(publishRelease(f.dependencies), /anderen Commit/);
|
||||
assert.throws(() => assertManifest({ ...f.manifest, commit: 'bad' }, f.plan, f.config));
|
||||
const malformed = structuredClone(f.manifest);
|
||||
malformed.images[0].digest = 'latest';
|
||||
assert.throws(() => assertManifest(malformed, f.plan, f.config));
|
||||
});
|
||||
|
||||
test('release assets pin compose, environment and installer to one version', () => {
|
||||
const f = fixture();
|
||||
const files = releaseFiles(f.manifest, f.plan.notes);
|
||||
assert.match(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-1\.2\.0/);
|
||||
assert.doesNotMatch(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-dev/);
|
||||
assert.match(files.get('.env.example').toString(), /^FEDEO_VERSION=1\.2\.0$/m);
|
||||
assert.match(files.get('selfhost-install.sh').toString(), /FEDEO_REF:-v1\.2\.0/);
|
||||
assert.equal(files.get('SHA256SUMS').toString().trim().split('\n').length, files.size - 1);
|
||||
});
|
||||
|
||||
test('Gitea API differentiates missing resources from permission and server errors', async () => {
|
||||
const api = new Gitea('https://git.example.test/owner/repo', 'secret', async () => new Response('', { status: 404 }));
|
||||
assert.equal(await api.release('v1.0.0'), null);
|
||||
api.fetch = async () => new Response('', { status: 403 });
|
||||
await assert.rejects(api.release('v1.0.0'), /HTTP 403/);
|
||||
api.fetch = async () => new Response('', { status: 500 });
|
||||
await assert.rejects(api.release('v1.0.0'), /HTTP 500/);
|
||||
});
|
||||
|
||||
test('Gitea download follows object-storage redirects without leaking the token', async () => {
|
||||
const requests = [];
|
||||
const api = new Gitea('https://git.example.test/owner/repo', 'secret', async (url, options) => {
|
||||
requests.push({ url, options });
|
||||
if (url.endsWith('/assets')) return Response.json([{ id: 2, name: 'manifest', browser_download_url: 'https://git.example.test/attachments/uuid' }]);
|
||||
if (url.includes('/attachments/')) return new Response(null, { status: 302, headers: { location: 'https://objects.example.test/signed-file' } });
|
||||
return new Response('file bytes');
|
||||
});
|
||||
assert.equal((await api.asset({ id: 1 }, 'manifest')).toString(), 'file bytes');
|
||||
assert.equal(requests[1].options.headers.Authorization, 'token secret');
|
||||
assert.deepEqual(requests[2].options.headers, {});
|
||||
});
|
||||
|
||||
test('Gitea upload retries compare existing bytes and refuse conflicting assets', async () => {
|
||||
const api = new Gitea('https://git.example.test/owner/repo', 'secret');
|
||||
api.request = async () => [{ id: 1, name: 'manifest' }];
|
||||
api.asset = async () => Buffer.from('original');
|
||||
await api.upload({ id: 1 }, 'manifest', Buffer.from('original'));
|
||||
await assert.rejects(api.upload({ id: 1 }, 'manifest', Buffer.from('changed')), /anderem Inhalt/);
|
||||
});
|
||||
Reference in New Issue
Block a user