diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 407481f..d5ef3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,8 +57,9 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_TITLE: ${{ github.event.pull_request.title }} + COMMIT_CONVENTION_GRANDFATHER: edb0ade331c54f380ae33abe7816c5a92f3a590a run: | - node scripts/validate-commit-message.mjs --range "$BASE_SHA" "$HEAD_SHA" + node scripts/validate-commit-message.mjs --range "$BASE_SHA" "$HEAD_SHA" --grandfather-through "$COMMIT_CONVENTION_GRANDFATHER" node scripts/validate-commit-message.mjs --title "$PR_TITLE" - name: Check changed files for whitespace errors diff --git a/docs/development-workflow.md b/docs/development-workflow.md index 0e1b7a1..5ded476 100644 --- a/docs/development-workflow.md +++ b/docs/development-workflow.md @@ -36,7 +36,7 @@ Human-authored commits use `[] `. The prefi There is no space between the Gitmoji and `[Action]`; exactly one space separates the closing bracket from a non-empty, concise imperative subject. Each commit represents one logical change. For example, `✨[Feat] Add Image to Text OCR` and `✅[Test] Cover OCR cancellation` are valid; `feat: add OCR`, `✨ [Feat] Add OCR`, and `✨[Fix] Add OCR` are invalid. -CI validates non-merge commits introduced by the pull request’s actual base-to-head range and validates the pull-request title with the same structural rule. Technical merge commits are excluded by their multiple-parent topology so normal merge commits remain supported. Published non-conforming history is retained and never rewritten solely for message compliance. +CI validates non-merge commits introduced by the pull request’s actual base-to-head range and validates the pull-request title with the same structural rule. Technical merge commits are excluded by their multiple-parent topology so normal merge commits remain supported. Published non-conforming history through `edb0ade331c54f380ae33abe7816c5a92f3a590a` is an explicit grandfather boundary: ancestors of that commit are excluded from later ranges, while every human-authored commit after it remains subject to validation. That history is retained and never rewritten solely for message compliance. ## Production release diff --git a/scripts/validate-commit-message.mjs b/scripts/validate-commit-message.mjs index c3e0c83..84e181a 100644 --- a/scripts/validate-commit-message.mjs +++ b/scripts/validate-commit-message.mjs @@ -38,13 +38,15 @@ function git(argumentsList, cwd) { return execFileSync("git", argumentsList, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); } -export function nonMergeCommits(base, head, cwd = process.cwd()) { - const output = git(["rev-list", "--reverse", "--no-merges", `${base}..${head}`], cwd); +export function nonMergeCommits(base, head, cwd = process.cwd(), grandfatherThrough = null) { + const argumentsList = ["rev-list", "--reverse", "--no-merges", `${base}..${head}`]; + if (grandfatherThrough) argumentsList.push(`^${grandfatherThrough}`); + const output = git(argumentsList, cwd); return output ? output.split(/\r?\n/) : []; } -export function validateCommitRange(base, head, cwd = process.cwd()) { - return nonMergeCommits(base, head, cwd).map((sha) => { +export function validateCommitRange(base, head, cwd = process.cwd(), grandfatherThrough = null) { + return nonMergeCommits(base, head, cwd, grandfatherThrough).map((sha) => { const subject = git(["show", "-s", "--format=%s", sha], cwd); return { sha, subject, ...validateCommitMessage(subject) }; }); @@ -69,17 +71,19 @@ export function run(argumentsList = process.argv.slice(2), cwd = process.cwd()) return; } - if (mode === "--range" && values.length === 2) { - const results = validateCommitRange(values[0], values[1], cwd); + if (mode === "--range" && (values.length === 2 || (values.length === 4 && values[2] === "--grandfather-through"))) { + const grandfatherThrough = values[3] || null; + const results = validateCommitRange(values[0], values[1], cwd, grandfatherThrough); const failures = results.filter((result) => !result.valid); if (failures.length) { throw new Error(failures.map((failure) => failureText(`commit ${failure.sha}`, failure.subject, failure.reason)).join("\n\n")); } - console.log(`Validated ${results.length} non-merge commit${results.length === 1 ? "" : "s"} in ${values[0]}..${values[1]}.`); + const boundary = grandfatherThrough ? ` after grandfather boundary ${grandfatherThrough}` : ""; + console.log(`Validated ${results.length} non-merge commit${results.length === 1 ? "" : "s"} in ${values[0]}..${values[1]}${boundary}.`); return; } - throw new Error("Usage: node scripts/validate-commit-message.mjs --message | --title | --range <base-sha> <head-sha>"); + throw new Error("Usage: node scripts/validate-commit-message.mjs --message <subject> | --title <title> | --range <base-sha> <head-sha> [--grandfather-through <sha>]"); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { diff --git a/tests/ci-foundation.test.mjs b/tests/ci-foundation.test.mjs index 4118666..478d19f 100644 --- a/tests/ci-foundation.test.mjs +++ b/tests/ci-foundation.test.mjs @@ -21,7 +21,8 @@ assert.match(workflow, /name: Enforce commit and pull request title conventions/ assert.match(workflow, /BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}/); assert.match(workflow, /HEAD_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/); assert.match(workflow, /PR_TITLE: \$\{\{ github\.event\.pull_request\.title \}\}/); -assert.match(workflow, /node scripts\/validate-commit-message\.mjs --range "\$BASE_SHA" "\$HEAD_SHA"/); +assert.match(workflow, /COMMIT_CONVENTION_GRANDFATHER: edb0ade331c54f380ae33abe7816c5a92f3a590a/); +assert.match(workflow, /node scripts\/validate-commit-message\.mjs --range "\$BASE_SHA" "\$HEAD_SHA" --grandfather-through "\$COMMIT_CONVENTION_GRANDFATHER"/); assert.match(workflow, /node scripts\/validate-commit-message\.mjs --title "\$PR_TITLE"/); assert.match(workflow, /git diff --check/); assert.match(workflow, /run: npm ci --ignore-scripts/); diff --git a/tests/commit-message.test.mjs b/tests/commit-message.test.mjs index f8304aa..1e4982f 100644 --- a/tests/commit-message.test.mjs +++ b/tests/commit-message.test.mjs @@ -81,6 +81,16 @@ try { assert.equal(failures.length, 1); assert.equal(failures[0].subject, "test: add invalid fixture"); assert.match(failures[0].sha, /^[0-9a-f]{40}$/); + + fs.writeFileSync(path.join(temporaryRepository, "current.txt"), "current\n"); + git("add", "current.txt"); + git("commit", "-q", "-m", "🐛[Fix] Validate commits after enforcement"); + const currentHead = git("rev-parse", "HEAD"); + const grandfathered = validateCommitRange(base, currentHead, temporaryRepository, invalidHead); + assert.equal(grandfathered.length, 1, "published ancestors through the enforcement boundary are excluded"); + assert.equal(grandfathered[0].subject, "🐛[Fix] Validate commits after enforcement"); + assert.equal(grandfathered[0].valid, true); + assert.doesNotThrow(() => run(["--range", base, currentHead, "--grandfather-through", invalidHead], temporaryRepository)); } finally { fs.rmSync(temporaryRepository, { recursive: true, force: true }); }