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; }