import { execFileSync, spawnSync } from 'node:child_process'; import { appendFileSync, readFileSync } from 'node:fs'; import { readCiContext } from './ci-context.mjs'; 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, tag, branch, slug, pullRequest, before } = readCiContext(); 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 (tag) { requirePolicy( /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(tag), 'Release tags must use vMAJOR.MINOR.PATCH without leading zeroes.', ); const { version } = JSON.parse(readFileSync('package.json', 'utf8')); requirePolicy(tag === `v${version}`, 'Release tag must match package.json.'); requirePolicy(ancestor(commit, 'origin/main'), 'Release commit must belong to main.'); } else { const target = pullRequest ? pullRequest.base?.ref : branch === 'main' || branch?.startsWith('hotfix/') ? 'main' : 'develop'; requirePolicy(branch, 'A branch name is required.'); git('check-ref-format', `refs/heads/${branch}`); requirePolicy(['main', 'develop'].includes(target), 'PRs must target main or develop.'); if (pullRequest) { const title = pullRequest.title?.replace( /^(?:Draft:|WIP:|\[Draft\]|\[WIP\]|\(Draft\)|\(WIP\))\s*/i, '', ); requirePolicy( conventional.test(title ?? ''), 'PR 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 base = !pullRequest && 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}`, ); } } if (env.GITHUB_OUTPUT) { appendFileSync(env.GITHUB_OUTPUT, `slug=${slug}\ntag=${tag}\n`); } console.log('GitFlow, version and commit policy passed.'); } catch (error) { console.error(error.message); process.exitCode = 1; }