From 007d3e0ca8be4f42315983211b7396d9da4295a9 Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Tue, 13 Oct 2026 01:42:19 +0200 Subject: [PATCH] ci: reject AI attribution and non-conventional commit messages --- .github/AGENTS.md | 3 + .github/CLAUDE.md | 1 + .github/hooks/AGENTS.md | 5 + .github/hooks/CLAUDE.md | 1 + .github/hooks/commit-msg | 12 ++ .github/scripts/AGENTS.md | 5 + .github/scripts/CLAUDE.md | 1 + .github/scripts/ci/AGENTS.md | 9 + .github/scripts/ci/CLAUDE.md | 1 + .github/scripts/ci/commit-message.mts | 197 ++++++++++++++++++++ .github/scripts/ci/commit-message.test.mts | 204 +++++++++++++++++++++ .github/scripts/ci/tsconfig.json | 17 ++ .github/workflows/test.yml | 20 ++ CONTRIBUTING.md | 28 +++ package.json | 2 +- vitest.config.ts | 2 +- 16 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 .github/AGENTS.md create mode 120000 .github/CLAUDE.md create mode 100644 .github/hooks/AGENTS.md create mode 120000 .github/hooks/CLAUDE.md create mode 100755 .github/hooks/commit-msg create mode 100644 .github/scripts/AGENTS.md create mode 120000 .github/scripts/CLAUDE.md create mode 100644 .github/scripts/ci/AGENTS.md create mode 120000 .github/scripts/ci/CLAUDE.md create mode 100644 .github/scripts/ci/commit-message.mts create mode 100644 .github/scripts/ci/commit-message.test.mts create mode 100644 .github/scripts/ci/tsconfig.json create mode 100644 CONTRIBUTING.md diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 0000000..3f96e9f --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,3 @@ +# Repository automation + +Owns CI workflows and opt-in commit policy tooling. No application code. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/.github/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/hooks/AGENTS.md b/.github/hooks/AGENTS.md new file mode 100644 index 0000000..1ad53c2 --- /dev/null +++ b/.github/hooks/AGENTS.md @@ -0,0 +1,5 @@ +# Commit policy automation + +Owns the dependency-free commit checker, adjacent tests and local hook. +Keep message rules aligned with the reference and the author allowlist explicit. +No application imports, credentials, publishing or automatic hook installation. diff --git a/.github/hooks/CLAUDE.md b/.github/hooks/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/.github/hooks/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/hooks/commit-msg b/.github/hooks/commit-msg new file mode 100755 index 0000000..00f005e --- /dev/null +++ b/.github/hooks/commit-msg @@ -0,0 +1,12 @@ +#!/bin/sh +# commit-policy-commit-message-hook: one Conventional Commits line per authored commit, no attribution. +# Run by the dispatcher the opt-in installer sets as core.hooksPath (.github/scripts/ci/commit-message.mts --install), or +# directly when core.hooksPath is .github/hooks; CI checks the same rules on every PR. Skip once with git commit --no-verify. +command -v node >/dev/null 2>&1 || { echo "commit-msg: node is missing" >&2; exit 1; } +root=$(git rev-parse --show-toplevel) || exit 1 +# Missing inputs must not silently disable validation; older branches can explicitly use --no-verify. +[ -f "$root/.github/scripts/ci/commit-message.mts" ] || { + echo "commit-msg: .github/scripts/ci/commit-message.mts is missing; restore it or explicitly use --no-verify" >&2 + exit 1 +} +exec node "$root/.github/scripts/ci/commit-message.mts" "$1" diff --git a/.github/scripts/AGENTS.md b/.github/scripts/AGENTS.md new file mode 100644 index 0000000..1ad53c2 --- /dev/null +++ b/.github/scripts/AGENTS.md @@ -0,0 +1,5 @@ +# Commit policy automation + +Owns the dependency-free commit checker, adjacent tests and local hook. +Keep message rules aligned with the reference and the author allowlist explicit. +No application imports, credentials, publishing or automatic hook installation. diff --git a/.github/scripts/CLAUDE.md b/.github/scripts/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/.github/scripts/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/scripts/ci/AGENTS.md b/.github/scripts/ci/AGENTS.md new file mode 100644 index 0000000..ddd70b4 --- /dev/null +++ b/.github/scripts/ci/AGENTS.md @@ -0,0 +1,9 @@ +# Commit policy checker + +Owns the dependency-free commit-message.mts checker and adjacent tests. +Reuse the reference message rules; AUTHOR_EMAILS is the explicit author policy. +Node built-ins only in the checker. Tests use disposable Git objects and hooks; +never change the caller's Git identity, history or hooks. No application imports. + +Verify with the repository Vitest runner against commit-message.test.mts. +Set TMPDIR inside this worktree. Strict typing is included in npm run typecheck. diff --git a/.github/scripts/ci/CLAUDE.md b/.github/scripts/ci/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/.github/scripts/ci/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.github/scripts/ci/commit-message.mts b/.github/scripts/ci/commit-message.mts new file mode 100644 index 0000000..c8dd1a2 --- /dev/null +++ b/.github/scripts/ci/commit-message.mts @@ -0,0 +1,197 @@ +// Commit messages: every authored commit is one Conventional Commits line with no body, trailers or attribution. +// Messages Git itself writes are accepted as Git wrote them (merges, `git revert`, `--fixup`/`--squash`). The same +// rules run in the local commit-msg hook (fast feedback) and in CI over a pull request's commits +// (the enforcement, since a hook can be skipped). Depends only on Node built-ins: CI runs it right after +// checkout, before any install. +// +// node .github/scripts/ci/commit-message.mts check one message (the commit-msg hook) +// node .github/scripts/ci/commit-message.mts --range .. check every commit in a range (CI) +// node .github/scripts/ci/commit-message.mts --install install the commit-msg hook into this clone (opt-in) +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { chmod, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export const AUTHOR_EMAILS: readonly string[] = ['juancfortunatti@gmail.com']; + +export const TYPES = ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'] as const; +export const MAX_LENGTH = 150; +const CONVENTIONAL = new RegExp(`^(${TYPES.join('|')})(\\([a-z0-9._/-]+\\))?!?: \\S(.*\\S)?$`, 'u'); +const ATTRIBUTION = /co-authored-by|generated (with|by)|🤖/iu; +const HOOK_SOURCE = '.github/hooks/commit-msg'; +/** Where branches from before the hooks moved under `.github/` keep it; the shared dispatcher still runs theirs. */ +const EARLIER_HOOK_SOURCE = '.githooks/commit-msg'; +/** The opt-in hooks directory (`git config core.hooksPath .github/hooks`) and the one it replaced. */ +const OPT_IN_HOOKS_PATH = '.github/hooks', EARLIER_HOOKS_PATH = '.githooks'; +const HOOK_MARKER = 'commit-policy-commit-message-hook'; + +/** The message as Git stores it: comment lines and trailing blank lines removed (the default `strip` cleanup). */ +export function cleanMessage(raw: string): string[] { + const lines = raw.replace(/\r\n?/gu, '\n').split('\n').filter(line => !line.startsWith('#')); + while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop(); + while (lines.length && !lines[0]!.trim()) lines.shift(); + return lines; +} + +/** Messages Git writes itself: a merge, `git revert`, and the `--fixup`/`--squash` autosquash markers. */ +export function isGitGenerated(firstLine: string): boolean { + return firstLine.startsWith('Merge ') || firstLine.startsWith('Revert "') || + /^(fixup|squash|amend)! /u.test(firstLine); +} + +/** Why an authored message is rejected, or undefined when it is acceptable. */ +export function messageProblem(raw: string): string | undefined { + const lines = cleanMessage(raw), first = lines[0] ?? ''; + if (!first.trim()) return 'The message is empty.'; + if (isGitGenerated(first)) return undefined; + if (lines.some(line => ATTRIBUTION.test(line))) return 'Remove attribution (Co-Authored-By, "Generated with …").'; + if (lines.length > 1) return 'Use a single line: no body and no trailers.'; + if (!CONVENTIONAL.test(first)) { + return `Use Conventional Commits: ()?!?: , with a lowercase type from ${TYPES.join(', ')}, and a scope of a-z, 0-9, ".", "_", "/" or "-" (no commas or spaces). Got: ${first}`; + } + if (first.length > MAX_LENGTH) return `Keep the line to ${MAX_LENGTH} characters; it has ${first.length}.`; + return undefined; +} + +export interface RangeCommit { + readonly sha: string; + readonly parents: number; + readonly authorEmail: string; + readonly message: string; +} + +export function authorProblem(email: string): string | undefined { + return AUTHOR_EMAILS.includes(email) ? undefined : + `Author email must be ${AUTHOR_EMAILS.join(' or ')}; got ${email || '(empty)'}.`; +} + +export function rangeProblems(commits: readonly RangeCommit[]): string[] { + return commits.flatMap(commit => { + // A merge's message is Git's own, whatever its text. + const problem = authorProblem(commit.authorEmail) ?? + (commit.parents > 1 ? undefined : messageProblem(commit.message)); + return problem ? [`${commit.sha.slice(0, 10)} ${cleanMessage(commit.message)[0] ?? ''}\n ${problem}`] : []; + }); +} + +export async function commitsIn(range: string, root: string): Promise { + const options = { cwd: root, maxBuffer: 1024 * 1024 * 64 }; + const { stdout } = await execFileAsync('git', ['log', '--format=%H', range, '--'], options); + const commits: RangeCommit[] = []; + // Read each message separately: user-controlled record separators cannot hide later commits. + for (const sha of stdout.trim().split('\n').filter(Boolean)) { + const { stdout: record } = await execFileAsync('git', + ['show', '--no-patch', '--format=%P%x00%ae%x00%B', sha], options); + const first = record.indexOf('\0'), second = record.indexOf('\0', first + 1); + if (first < 0 || second < 0) throw new Error(`Cannot read commit ${sha}.`); + commits.push({ sha, parents: record.slice(0, first).split(' ').filter(Boolean).length, + authorEmail: record.slice(first + 1, second), message: record.slice(second + 1) }); + } + return commits; +} + +export async function currentAuthorEmail(root: string): Promise { + const { stdout } = await execFileAsync('git', ['var', 'GIT_AUTHOR_IDENT'], { cwd: root }); + const match = /<([^<>]*)> \d+ [+-]\d+\s*$/u.exec(stdout); + if (!match) throw new Error('Cannot read the effective Git author identity.'); + return match[1]!; +} + +/** The hooks directory `--install` owns, inside the clone's common Git directory, so every worktree shares it. */ +export const HOOKS_DIRECTORY = 'commit-policy-hooks'; + +/** Runs the calling worktree's own tracked hook. It names no checker path, so it stays correct on every branch + * layout, and it lives outside `.git/hooks`, which an older branch's installer overwrites. */ +/** Marks a file this installer wrote into its hooks directory; nothing else there is ever overwritten. */ +export const DISPATCHER_MARKER = 'commit-policy-commit-msg-dispatcher'; +export const DISPATCHER = `#!/bin/sh +# ${HOOK_MARKER} ${DISPATCHER_MARKER}: run this worktree's tracked ${HOOK_SOURCE}. Installed explicitly. +root=$(git rev-parse --show-toplevel) || { echo "commit-msg: cannot find this worktree's top level; run git commit from inside the worktree." >&2; exit 1; } +hook="$root/${HOOK_SOURCE}" +[ -f "$hook" ] || [ ! -f "$root/${EARLIER_HOOK_SOURCE}" ] || hook="$root/${EARLIER_HOOK_SOURCE}" +[ -f "$hook" ] || { echo "commit-msg: $root/${HOOK_SOURCE} is missing; restore it (git checkout -- ${HOOK_SOURCE}), or skip once with git commit --no-verify." >&2; exit 1; } +exec sh "$hook" "$@" +`; + +const git = async (root: string, ...args: string[]) => (await execFileAsync('git', args, { cwd: root })).stdout.trim(); + +/** Point this clone's core.hooksPath at a dispatcher that runs each worktree's own tracked hook. Git then never reads + * `.git/hooks`, so a copy an older branch's installer writes there cannot replace the check; that installer also + * returns early once core.hooksPath is set. Leaves any core.hooksPath already configured (including the pre-push + * opt-in `.github/hooks`) and never disables a hook of the contributor's own in `.git/hooks`. */ +export async function installHook(root: string): Promise { + let common: string, configured: string | undefined; + try { + common = resolve(root, await git(root, 'rev-parse', '--git-common-dir')); + // `git config --get` fails when the key is unset; an explicitly empty value is a setting too, and is kept. + configured = await git(root, 'config', '--get', 'core.hooksPath').catch(() => undefined); + } catch { + return 'Not a Git checkout; no hook installed.'; + } + // Not `git rev-parse --git-path hooks`: that answers core.hooksPath once it is set. + const legacy = resolve(common, 'hooks'), directory = resolve(common, HOOKS_DIRECTORY), target = resolve(directory, 'commit-msg'); + // The opt-in from before the hooks moved under `.github/` names a directory this layout no longer has, so Git would run + // no hook at all. Carry the opt-in over to the directory that replaced it. + if (configured !== undefined && resolve(root, configured) === resolve(root, EARLIER_HOOKS_PATH) + && !existsSync(resolve(root, EARLIER_HOOKS_PATH)) && existsSync(resolve(root, OPT_IN_HOOKS_PATH))) { + await git(root, 'config', 'core.hooksPath', OPT_IN_HOOKS_PATH); + return `core.hooksPath was ${JSON.stringify(configured)}, which no longer exists; it is now ${JSON.stringify(OPT_IN_HOOKS_PATH)}.`; + } + if (configured !== undefined && (!configured || resolve(root, configured) !== directory)) return `core.hooksPath is ${JSON.stringify(configured)}; left unchanged (${HOOK_SOURCE} applies when that path is .github/hooks).`; + if (configured === undefined) { + const own: string[] = []; + for (const name of await readdir(legacy).catch(() => [] as string[])) { + if (name.endsWith('.sample')) continue; + const text = await readFile(resolve(legacy, name), 'utf8').catch(() => ''); + if (!(name === 'commit-msg' && text.includes(HOOK_MARKER))) own.push(name); + } + if (own.length) return `${legacy} has hooks of your own (${own.join(', ')}); left unchanged. Set core.hooksPath to run ${HOOK_SOURCE}.`; + } + const current = await readFile(target, 'utf8').catch(() => undefined); + if (current !== undefined && !current.includes(DISPATCHER_MARKER)) return `${target} is not this installer's dispatcher; left unchanged.`; + await mkdir(directory, { recursive: true }); + await writeFile(target, DISPATCHER); + await chmod(target, 0o755); + await git(root, 'config', 'core.hooksPath', directory); + // The copy an earlier install left in .git/hooks is no longer read; remove it so nothing looks installed twice. + const copy = resolve(legacy, 'commit-msg'); + if ((await readFile(copy, 'utf8').catch(() => '')).includes(HOOK_MARKER)) await rm(copy, { force: true }); + return `Installed the commit-msg hook: core.hooksPath is ${directory}.`; +} + +async function main(args: readonly string[]): Promise { + const root = resolve(import.meta.dirname, '../../..'); + if (args[0] === '--install' && args.length === 1) { + console.log(`commit-message: ${await installHook(root)}`); + return 0; + } + if (args[0] === '--range' && args.length === 2 && args[1]) { + const problems = rangeProblems(await commitsIn(args[1], root)); + if (!problems.length) return 0; + console.error(`Commit authors must be allowlisted; messages must be one Conventional Commits line with no attribution:\n${problems.join('\n')}\n` + + 'Reword them (git rebase -i, then "reword"), and see CONTRIBUTING.md#commits.'); + return 1; + } + if (args.length === 1 && args[0] && !args[0].startsWith('--')) { + const problem = authorProblem(await currentAuthorEmail(root)) ?? + messageProblem(await readFile(args[0], 'utf8')); + if (!problem) return 0; + console.error(`commit-msg: ${problem}\nSee CONTRIBUTING.md#commits. Skip once with git commit --no-verify; CI still checks.`); + return 1; + } + console.error('Usage: commit-message.mts | --range .. | --install'); + return 2; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + process.exitCode = await main(process.argv.slice(2)); + } catch (error) { + console.error(`commit-message: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/ci/commit-message.test.mts b/.github/scripts/ci/commit-message.test.mts new file mode 100644 index 0000000..4392569 --- /dev/null +++ b/.github/scripts/ci/commit-message.test.mts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { test } from 'vitest'; +import { AUTHOR_EMAILS, DISPATCHER_MARKER, HOOKS_DIRECTORY, MAX_LENGTH, TYPES, + authorProblem, cleanMessage, commitsIn, installHook, messageProblem, rangeProblems } from './commit-message.mts'; + +const allowed = 'juancfortunatti@gmail.com'; +const commit = (message: string, overrides: Partial = {}): RangeCommit => + ({ sha: 'a'.repeat(40), parents: 1, authorEmail: allowed, message, ...overrides }); +import type { RangeCommit } from './commit-message.mts'; + +test('one Conventional Commits line passes for every allowed type, scopes and breaking markers', () => { + assert.deepEqual(AUTHOR_EMAILS, [allowed]); + for (const type of TYPES) assert.equal(messageProblem(`${type}: update`), undefined); + for (const message of ['refactor(core): move reader\n', 'fix!: drop old reader\n', + 'ci(commit-message): check every commit', 'chore(deps/pnpm): bump lockfile\n\n\n']) { + assert.equal(messageProblem(message), undefined, message); + } +}); + +test('Git comment blocks, CRLF and trailing blank lines follow the reference cleanup', () => { + assert.deepEqual(cleanMessage('\nfeat: add x\n\n# Please enter a message\n# On branch main\n'), ['feat: add x']); + assert.equal(messageProblem('feat: add x\r\n\r\n# comment\r\n'), undefined); +}); + +test('bodies, trailers and all attribution variants fail', () => { + for (const body of ['Why it matters.', 'Signed-off-by: test ']) { + assert.match(messageProblem(`feat: add x\n\n${body}\n`) ?? '', /single line/u); + } + for (const text of ['Co-Authored-By: test ', 'co-authored-by: test', + 'GENERATED WITH a tool', 'generated by a tool', '🤖']) { + assert.match(messageProblem(`feat: ${text}`) ?? '', /attribution/u); + assert.match(messageProblem(`feat: add x\n\n${text}\n`) ?? '', /attribution/u); + } +}); + +test('nonconventional titles, invalid scopes and whitespace fail', () => { + for (const message of ['moved reader', 'Refactor(core): move', 'feat(Core): move', 'feat(a,b): move', + 'feat(a b): move', 'feat(): move', 'feat:move', 'feat: ', 'wip: things', 'feat: trailing space ', '']) { + assert.notEqual(messageProblem(message), undefined, JSON.stringify(message)); + } +}); + +test('150 characters pass and 151 fail', () => { + assert.equal(messageProblem(`feat: ${'x'.repeat(MAX_LENGTH - 6)}`), undefined); + assert.match(messageProblem(`feat: ${'x'.repeat(MAX_LENGTH - 5)}`) ?? '', /characters/u); +}); + +test('Git-generated messages preserve the reference exemptions', () => { + for (const message of ["Merge branch 'main' into feature\n", 'Revert "feat: add x"\n\nThis reverts commit abc.\n', + 'fixup! feat: add x\n', 'squash! feat: add x\n\nmore\n', 'amend! feat: add x\n\nmore\n']) { + assert.equal(messageProblem(message), undefined, message); + } +}); + +test('the author allowlist rejects foreign, empty, GitHub and near-match emails', () => { + assert.equal(authorProblem(allowed), undefined); + for (const email of ['', 'bot@example.com', 'noreply@github.com', `${allowed}.evil`, allowed.toUpperCase()]) { + assert.match(authorProblem(email) ?? '', /Author email/u, email); + } +}); + +test('ranges skip merge messages while enforcing authors on merges and generated messages', () => { + const problems = rangeProblems([commit('feat: fine'), commit('bad title', { sha: 'b'.repeat(40) }), + commit('Any merge text\n\nwith a body', { parents: 2 }), + commit('Any merge text', { parents: 2, authorEmail: 'bot@example.com' }), + commit('fixup! feat: fine', { authorEmail: 'bot@example.com' })]); + assert.equal(problems.length, 3); + assert.match(problems[0]!, /^bbbbbbbbbb bad title\n {2}Use Conventional Commits/u); + assert.match(problems[1]!, /Author email/u); + assert.match(problems[2]!, /Author email/u); +}); + +function fixture() { + const root = mkdtempSync(resolve(tmpdir(), 'commit-message-')); + const env = { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: allowed, GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: allowed }; + const git = (...args: string[]) => execFileSync('git', args, { cwd: root, env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + git('init', '-q'); + git('config', 'user.name', 'test'); + git('config', 'user.email', allowed); + for (const path of ['.github/hooks/commit-msg', '.github/scripts/ci/commit-message.mts']) { + mkdirSync(dirname(resolve(root, path)), { recursive: true }); + copyFileSync(resolve(import.meta.dirname, '../../..', path), resolve(root, path)); + } + chmodSync(resolve(root, '.github/hooks/commit-msg'), 0o755); + const tree = git('mktree'); + const makeCommit = (message: string, parents: string[] = [], author = allowed, committer = allowed) => + execFileSync('git', ['commit-tree', tree, ...parents.flatMap(parent => ['-p', parent]), '-m', message], + { cwd: root, env: { ...env, GIT_AUTHOR_EMAIL: author, GIT_COMMITTER_EMAIL: committer }, encoding: 'utf8' }).trim(); + const start = makeCommit('chore: start'); + git('update-ref', 'HEAD', start); + const checker = resolve(root, '.github/scripts/ci/commit-message.mts'); + const run = (...args: string[]) => execFileSync(process.execPath, [checker, ...args], { cwd: root, env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + const hook = (message: string, author = allowed) => { + const file = resolve(root, 'message'); + writeFileSync(file, message); + return execFileSync('git', ['hook', 'run', 'commit-msg', '--', file], + { cwd: root, env: { ...env, GIT_AUTHOR_EMAIL: author }, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + }; + return { root, env, git, start, makeCommit, checker, run, hook }; +} + +test('real Git ranges use the original author, permit GitHub committers and preserve message separators', async () => { + const f = fixture(); + try { + const good = f.makeCommit('feat: good', [f.start], allowed, 'noreply@github.com'); + const bad = f.makeCommit('feat: bad\n\n\u001e\u001fhidden', [good], 'bot@example.com'); + const merge = f.makeCommit('arbitrary merge body\n\nmore', [good, bad]); + const commits = await commitsIn(`${f.start}..${merge}`, f.root); + assert.equal(commits.length, 3); + assert.equal(commits.find(c => c.sha === good)?.authorEmail, allowed); + assert.match(commits.find(c => c.sha === bad)?.message ?? '', /\u001e\u001fhidden/u); + const problems = rangeProblems(commits); + assert.equal(problems.length, 1); + assert.match(problems[0]!, /bot@example.com/u); + f.run('--range', `${f.start}..${good}`); + assert.throws(() => f.run('--range', `${f.start}..${merge}`), /Author email/u); + const foreignMerge = f.makeCommit('Merge branches', [good, bad], 'bot@example.com', 'noreply@github.com'); + assert.equal(rangeProblems(await commitsIn(`${merge}..${foreignMerge}`, f.root)).length, 1); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('invalid ranges and CLI arguments fail closed', () => { + const f = fixture(); + try { + assert.throws(() => f.run('--range', 'missing-ref..HEAD'), /unknown revision|ambiguous argument|bad revision/u); + assert.throws(() => f.run('--unknown'), /Usage/u); + assert.throws(() => f.run('missing-file'), /ENOENT/u); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('the installed hook rejects bad messages and author overrides, and reinstalls idempotently', async () => { + const f = fixture(); + try { + assert.match(await installHook(f.root), /Installed/u); + assert.equal(f.git('config', '--get', 'core.hooksPath'), resolve(f.root, '.git', HOOKS_DIRECTORY)); + assert.throws(() => f.hook('Bad message'), /Conventional Commits/u); + assert.throws(() => f.hook('chore: fine\n\nbody'), /single line/u); + assert.throws(() => f.hook('chore: generated by a tool'), /attribution/u); + assert.throws(() => f.hook('chore: fine', 'bot@example.com'), /Author email/u); + assert.throws(() => f.hook('Merge branch main', 'bot@example.com'), /Author email/u); + f.hook('chore: fine'); + assert.match(await installHook(f.root), /Installed/u); + assert.throws(() => f.hook('Bad message'), /Conventional Commits/u); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('the installer preserves user hooks, custom paths, empty settings and foreign dispatchers', async () => { + const f = fixture(); + try { + const own = resolve(f.root, '.git/hooks/pre-commit'); + writeFileSync(own, '#!/bin/sh\necho mine\n', { mode: 0o755 }); + assert.match(await installHook(f.root), /hooks of your own/u); + assert.throws(() => f.git('config', '--get', 'core.hooksPath')); + rmSync(own); + for (const path of ['custom-hooks', '']) { + f.git('config', 'core.hooksPath', path); + assert.match(await installHook(f.root), /left unchanged/u); + assert.equal(f.git('config', '--get', 'core.hooksPath'), path); + } + f.git('config', '--unset', 'core.hooksPath'); + const target = resolve(f.root, '.git', HOOKS_DIRECTORY, 'commit-msg'); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, '#!/bin/sh\necho mine\n'); + assert.match(await installHook(f.root), /not this installer's dispatcher/u); + assert.equal(readFileSync(target, 'utf8'), '#!/bin/sh\necho mine\n'); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('direct opt-in hooks and shared dispatchers fail when tracked inputs disappear', async () => { + const f = fixture(); + try { + f.git('config', 'core.hooksPath', '.github/hooks'); + f.hook('chore: fine'); + rmSync(f.checker); + assert.throws(() => f.hook('chore: fine'), /commit-message\.mts is missing/u); + copyFileSync(resolve(import.meta.dirname, 'commit-message.mts'), f.checker); + f.git('config', '--unset', 'core.hooksPath'); + assert.match(await installHook(f.root), /Installed/u); + assert.match(readFileSync(resolve(f.root, '.git', HOOKS_DIRECTORY, 'commit-msg'), 'utf8'), new RegExp(DISPATCHER_MARKER, 'u')); + rmSync(resolve(f.root, '.github/hooks/commit-msg')); + assert.throws(() => f.hook('chore: fine'), /commit-msg is missing/u); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); + +test('the dispatcher follows the calling linked worktree and does not capture its install location', async () => { + const f = fixture(); + try { + await installHook(f.root); + const linked = resolve(f.root, 'linked'); + f.git('worktree', 'add', '-q', '--detach', linked, f.start); + mkdirSync(resolve(linked, '.github/hooks'), { recursive: true }); + writeFileSync(resolve(linked, '.github/hooks/commit-msg'), '#!/bin/sh\necho linked-worktree >&2\nexit 7\n'); + const message = resolve(linked, 'message'); + writeFileSync(message, 'chore: fine'); + assert.throws(() => execFileSync('git', ['hook', 'run', 'commit-msg', '--', message], + { cwd: linked, env: f.env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }), /linked-worktree/u); + f.hook('chore: fine'); + } finally { rmSync(f.root, { recursive: true, force: true }); } +}); diff --git a/.github/scripts/ci/tsconfig.json b/.github/scripts/ci/tsconfig.json new file mode 100644 index 0000000..f9b2434 --- /dev/null +++ b/.github/scripts/ci/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "*.mts" + ] +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9979356..c038d3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,26 @@ on: branches: [main] jobs: + commit-message: + name: Commit messages and author allowlist + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Check out pull request head with full history + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + - name: Check every pull request commit before install + run: node .github/scripts/ci/commit-message.mts --range "origin/$GITHUB_BASE_REF..HEAD" + test: runs-on: ubuntu-latest steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1618e84 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +## Commits + +Every authored commit must be one Conventional Commits line of at most 150 +characters: `type(scope)!: summary`, with scope and `!` optional. Allowed types: +feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. +No body, trailers, Co-Authored-By, "generated with/by", or robot emoji. +Git-generated Merge, Revert, fixup!, squash! and amend! messages are accepted; +merge commits skip message validation. All commits, including merges, must have +an author email of `juancfortunatti@gmail.com`. Committers may be GitHub +`noreply@github.com`; the allowlist checks the author, not the committer. + +To opt into the local hook, from a fresh clone or any linked worktree: + +```sh +cd "$(git rev-parse --show-toplevel)" +node .github/scripts/ci/commit-message.mts --install +``` + +Requires Node 22.19 or later, with no package install. The installer preserves +existing hooks and custom `core.hooksPath`; read its result. If it leaves an +existing setup unchanged, integrate `.github/hooks/commit-msg` into that setup +instead of replacing it. The shared dispatcher runs each worktree's own tracked +hook and fails if it is missing. Installation is opt-in and never runs on install. +The hook checks the effective Git author, including `GIT_AUTHOR_EMAIL` overrides. +`--no-verify` skips the local hook; CI still checks every PR commit in +`origin/$GITHUB_BASE_REF..HEAD` with full history, before dependency installation. diff --git a/package.json b/package.json index 721d320..90ad285 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "clean": "rm -rf dist", "prepack": "npm run build", "prepublishOnly": "npm run typecheck && npm test", - "typecheck": "tsc --project tsconfig.json --noEmit", + "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project .github/scripts/ci/tsconfig.json", "test": "vitest run" }, "engines": { diff --git a/vitest.config.ts b/vitest.config.ts index faa27d5..d9e17b0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"] + include: ["src/**/*.test.ts", ".github/scripts/ci/*.test.mts"] } });