feat(release): automate versioned releases on main
This commit is contained in:
93
scripts/release/lib.mjs
Normal file
93
scripts/release/lib.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { analyzeCommits } from '@semantic-release/commit-analyzer';
|
||||
import { generateNotes } from '@semantic-release/release-notes-generator';
|
||||
import semver from 'semver';
|
||||
|
||||
export const logger = { log() {} };
|
||||
export const stableTag = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
||||
const commitHeader = /^(feat|fix|perf|docs|test|refactor|build|ci|chore|style|revert)(\([^()\r\n]+\))?!?: \S.*$/;
|
||||
|
||||
export function git(args, cwd = process.cwd(), options = {}) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, ...options }).trim();
|
||||
}
|
||||
|
||||
export function readConfig(cwd = process.cwd()) {
|
||||
return JSON.parse(readFileSync(resolve(cwd, 'release.config.json'), 'utf8'));
|
||||
}
|
||||
|
||||
export function validCommit(message) {
|
||||
// Git's generated revert message is understood by the Conventional Commits preset.
|
||||
return commitHeader.test(message.split('\n')[0]) ||
|
||||
(/^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()) {
|
||||
// semantic-release's revert filter expects newest commits first.
|
||||
const hashes = git(['rev-list', '--no-merges', `${from}..${to}`], cwd);
|
||||
return hashes ? hashes.split('\n').map(hash => ({ hash, message: git(['show', '-s', '--format=%B', hash], cwd) })) : [];
|
||||
}
|
||||
|
||||
export function checkCommits(commits) {
|
||||
const invalid = commits.filter(commit => !validCommit(commit.message));
|
||||
if (invalid.length) throw new Error(`Conventional Commits fehlen:\n${invalid.map(c => `${c.hash.slice(0, 8)} ${c.message.split('\n')[0]}`).join('\n')}\nVerwende z. B. fix(scope): Beschreibung oder feat(scope): Beschreibung.`);
|
||||
}
|
||||
|
||||
export async function releaseType(commits, cwd = process.cwd()) {
|
||||
return analyzeCommits({ preset: 'conventionalcommits' }, { cwd, commits, logger });
|
||||
}
|
||||
|
||||
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.'); }
|
||||
}
|
||||
|
||||
export async function createPlan({ cwd = process.cwd(), config = readConfig(cwd), target = 'HEAD' } = {}) {
|
||||
const sha = git(['rev-parse', `${target}^{commit}`], cwd);
|
||||
assertBootstrap(config, sha, cwd);
|
||||
const tags = git(['tag', '--merged', sha], cwd).split('\n').filter(tag => stableTag.test(tag))
|
||||
.sort((a, b) => semver.rcompare(a.slice(1), b.slice(1)));
|
||||
const lastTag = tags[0];
|
||||
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));
|
||||
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.');
|
||||
const tag = version ? `v${version}` : null;
|
||||
const notes = version ? await generateNotes({
|
||||
preset: 'conventionalcommits', host: new URL(config.repositoryUrl).origin,
|
||||
commit: 'commit', issue: 'issues', linkCompare: Boolean(previousTag),
|
||||
}, {
|
||||
cwd, logger, commits, options: { repositoryUrl: config.repositoryUrl },
|
||||
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);
|
||||
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)}`,
|
||||
notes: version ? `${!previousTag ? 'Erster versionierter FEDEO-Release. Die ältere Entwicklungshistorie bleibt im Git-Repository erhalten.\n\n' : ''}${notes}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function componentVersions(sha, cwd = process.cwd()) {
|
||||
const json = path => JSON.parse(git(['show', `${sha}:${path}`], cwd));
|
||||
return {
|
||||
mobile: { version: json('mobile/app.json').expo.version, iosBuildNumber: json('mobile/app.json').expo.ios.buildNumber },
|
||||
deviceAgent: { version: json('agents/fedeo-device-agent/package.json').version },
|
||||
};
|
||||
}
|
||||
|
||||
export function migrationManifest(sha, cwd = process.cwd()) {
|
||||
return ['backend/db/migrations', 'push-server/packages/db/drizzle'].flatMap(folder => {
|
||||
const files = git(['ls-tree', '-r', '--name-only', sha, '--', folder], cwd);
|
||||
return files ? files.split('\n').filter(path => path.endsWith('.sql')).map(path => ({
|
||||
path, sha256: createHash('sha256').update(execFileSync('git', ['show', `${sha}:${path}`], { cwd })).digest('hex'),
|
||||
})) : [];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user