Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/development-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Human-authored commits use `<Gitmoji>[<Action>] <imperative subject>`. 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

Expand Down
20 changes: 12 additions & 8 deletions scripts/validate-commit-message.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
});
Expand All @@ -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 <subject> | --title <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]) {
Expand Down
3 changes: 2 additions & 1 deletion tests/ci-foundation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
10 changes: 10 additions & 0 deletions tests/commit-message.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
Loading