ci: enforce GitFlow and publish branch images
This commit is contained in:
1 parent
06adeb12d6
commit
2a53bb6cb4
4 files changed
+281
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user