ci: enforce GitFlow and publish branch images

This commit is contained in:
nessar committed 2026-10-08 00:09:29 +02:00
1 parent 06adeb12d6
commit 2a53bb6cb4
4 files changed
+281 -7

No files matched your search

+85
View File
@@ -0,0 +1,85 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
const env = process.env;
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
const ancestor = (base, commit) =>
spawnSync('git', ['merge-base', '--is-ancestor', base, commit]).status === 0;
const conventional = /^[a-z]+(?:\([^()\r\n]+\))?!?: \S.*$/i;
const requirePolicy = (condition, message) => {
if (!condition) throw new Error(message);
};
try {
const commit = env.CI_COMMIT_SHA;
requirePolicy(commit === git('rev-parse', 'HEAD'), 'CI must validate the checked-out commit.');
git(
'fetch',
'--no-tags',
'origin',
'+refs/heads/main:refs/remotes/origin/main',
'+refs/heads/develop:refs/remotes/origin/develop',
);
if (env.CI_COMMIT_TAG) {
requirePolicy(
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(env.CI_COMMIT_TAG),
'Release tags must use vMAJOR.MINOR.PATCH without leading zeroes.',
);
const { version } = JSON.parse(readFileSync('package.json', 'utf8'));
requirePolicy(env.CI_COMMIT_TAG === `v${version}`, 'Release tag must match package.json.');
requirePolicy(ancestor(commit, 'origin/main'), 'Release commit must belong to main.');
} else {
const mr = env.CI_PIPELINE_SOURCE === 'merge_request_event';
const branch = mr ? env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME : env.CI_COMMIT_BRANCH;
const target = mr
? env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME
: branch === 'main' || branch?.startsWith('hotfix/')
? 'main'
: 'develop';
requirePolicy(branch, 'A branch name is required.');
requirePolicy(['main', 'develop'].includes(target), 'MRs must target main or develop.');
if (mr) {
const title = env.CI_MERGE_REQUEST_TITLE?.replace(
/^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i,
'',
);
requirePolicy(
conventional.test(title ?? ''),
'MR title must use Conventional Commits.',
);
requirePolicy(
target !== 'main' || /^(release|hotfix)\/.+/.test(branch),
'Only release/* and hotfix/* may merge into main.',
);
if (target === 'develop' && branch !== 'main' && !/^(release|hotfix)\//.test(branch)) {
requirePolicy(
ancestor('origin/develop', commit),
'Rebase the working branch onto origin/develop before merging.',
);
requirePolicy(
!git('rev-list', '--merges', `origin/develop..${commit}`),
'Working branches must use rebase, not merge commits.',
);
}
}
const before = env.CI_COMMIT_BEFORE_SHA;
const base =
!mr && before && !/^0+$/.test(before) && ancestor(before, commit)
? before
: git('merge-base', `origin/${target}`, commit);
const subjects = git('log', '--format=%h %s', `${base}..${commit}`);
for (const line of subjects ? subjects.split('\n') : []) {
requirePolicy(
conventional.test(line.slice(line.indexOf(' ') + 1)),
`Commit must use Conventional Commits: ${line}`,
);
}
}
console.log('GitFlow, version and commit policy passed.');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const directory = mkdtempSync(join(tmpdir(), 'ci-policy-'));
const script = fileURLToPath(new URL('./ci-policy.mjs', import.meta.url));
const git = (...args) => execFileSync('git', args, { cwd: directory, encoding: 'utf8' }).trim();
const check = (variables, expected = 0) => {
const result = spawnSync(process.execPath, [script], {
cwd: directory,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
CI_PIPELINE_SOURCE: 'push',
CI_COMMIT_SHA: git('rev-parse', 'HEAD'),
CI_COMMIT_BRANCH: 'feature/test',
...variables,
},
});
assert.equal(result.status, expected, result.stdout + result.stderr);
};
const mr = {
CI_PIPELINE_SOURCE: 'merge_request_event',
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'feature/test',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'develop',
CI_MERGE_REQUEST_TITLE: 'feat(test): add policy checks',
};
try {
git('init', '--initial-branch=main');
git('config', 'user.name', 'CI policy test');
git('config', 'user.email', 'ci-policy@example.invalid');
git('remote', 'add', 'origin', directory);
writeFileSync(join(directory, 'package.json'), '{"version":"1.5.0"}\n');
git('add', 'package.json');
git('commit', '-m', 'Legacy history is allowed');
git('branch', 'develop');
git('switch', '-c', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat(test)!: add checks');
check({});
check(mr);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Draft: feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TITLE: '(Draft) feat: test policy' });
check({ ...mr, CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main' }, 1);
check({ ...mr, CI_MERGE_REQUEST_TITLE: 'Add checks' }, 1);
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_SHA: '0'.repeat(40) }, 1);
git('switch', 'develop');
git('commit', '--allow-empty', '-m', 'fix: another change');
git('switch', 'feature/test');
check(mr, 1);
git('rebase', 'develop');
check(mr);
const before = git('rev-parse', 'HEAD');
git('commit', '--allow-empty', '-m', 'not conventional');
check({ CI_COMMIT_BEFORE_SHA: before }, 1);
check(mr, 1);
git('commit', '--amend', '--allow-empty', '-m', 'ci: fix the commit title');
check({ CI_COMMIT_BEFORE_SHA: before });
git('switch', 'develop');
git('merge', '--no-ff', 'feature/test', '-m', 'feat(test): add policy checks');
git('switch', '-c', 'release/1.6.0');
writeFileSync(join(directory, 'package.json'), '{"version":"1.6.0"}\n');
git('add', 'package.json');
git('commit', '-m', 'chore(release): prepare 1.6.0');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'release/1.6.0' });
git('switch', 'main');
git('merge', '--no-ff', 'release/1.6.0', '-m', 'chore(release): publish 1.6.0');
check({ CI_COMMIT_TAG: 'v1.6.0' });
check({ CI_COMMIT_TAG: 'v1.5.0' }, 1);
check({ CI_COMMIT_TAG: 'v01.6.0' }, 1);
check({ CI_COMMIT_TAG: 'v1.6.0-rc.1' }, 1);
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'main' });
git('switch', '-c', 'hotfix/1.6.1');
git('commit', '--allow-empty', '-m', 'fix: production issue');
check({
...mr,
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1',
CI_MERGE_REQUEST_TARGET_BRANCH_NAME: 'main',
});
check({ ...mr, CI_MERGE_REQUEST_SOURCE_BRANCH_NAME: 'hotfix/1.6.1' });
// The working branch must not pass by merging develop instead of rebasing.
git('switch', 'feature/test');
git('commit', '--allow-empty', '-m', 'feat: keep working');
git('merge', '--no-ff', 'develop', '-m', 'chore: merge develop');
check(mr, 1);
console.log('CI policy integration checks passed.');
} finally {
rmSync(directory, { recursive: true, force: true });
}