Skip to content

Commit 515f856

Browse files
authored
ci: lint-commit messages (#70)
1 parent 385f1d7 commit 515f856

File tree

2 files changed

+195
-0
lines changed

2 files changed

+195
-0
lines changed

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,21 @@ on:
1212
branches: [ main ]
1313

1414
jobs:
15+
lint-commits:
16+
# Note: To re-run `lint-commits` after fixing the PR title, close-and-reopen the PR.
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v5
20+
- name: Use Node.js
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: 22.x
24+
- name: Check PR title
25+
run: |
26+
node "$GITHUB_WORKSPACE/.github/workflows/lintcommit.js"
27+
1528
build:
29+
needs: lint-commits
1630

1731
runs-on: ubuntu-latest
1832
strategy:

.github/workflows/lintcommit.js

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Checks that a PR title conforms to conventional commits
2+
// (https://www.conventionalcommits.org/).
3+
//
4+
// To run self-tests, run this script:
5+
//
6+
// node lintcommit.js test
7+
8+
import { readFileSync, appendFileSync } from "fs";
9+
10+
const types = new Set([
11+
"build",
12+
"chore",
13+
"parity",
14+
"ci",
15+
"config",
16+
"deps",
17+
"docs",
18+
"feat",
19+
"fix",
20+
"perf",
21+
"refactor",
22+
"revert",
23+
"style",
24+
"test",
25+
"types",
26+
]);
27+
28+
const scopes = new Set(["testing-sdk", "examples"]);
29+
30+
/**
31+
* Checks that a pull request title, or commit message subject, follows the expected format:
32+
*
33+
* type(scope): message
34+
*
35+
* Returns undefined if `title` is valid, else an error message.
36+
*/
37+
function validateTitle(title) {
38+
const parts = title.split(":");
39+
const subject = parts.slice(1).join(":").trim();
40+
41+
if (title.startsWith("Merge")) {
42+
return undefined;
43+
}
44+
45+
if (parts.length < 2) {
46+
return "missing colon (:) char";
47+
}
48+
49+
const typeScope = parts[0];
50+
51+
const [type, scope] = typeScope.split(/\(([^)]+)\)$/);
52+
53+
if (/\s+/.test(type)) {
54+
return `type contains whitespace: "${type}"`;
55+
} else if (!types.has(type)) {
56+
return `invalid type "${type}"`;
57+
} else if (!scope && typeScope.includes("(")) {
58+
return `must be formatted like type(scope):`;
59+
} else if (!scope && ["feat", "fix"].includes(type)) {
60+
return `"${type}" type must include a scope (example: "${type}(testing-sdk)")`;
61+
} else if (scope && scope.length > 30) {
62+
return "invalid scope (must be <=30 chars)";
63+
} else if (scope && /[^- a-z0-9]+/.test(scope)) {
64+
return `invalid scope (must be lowercase, ascii only): "${scope}"`;
65+
} else if (scope && !scopes.has(scope)) {
66+
return `invalid scope "${scope}" (valid scopes are ${Array.from(scopes).join(", ")})`;
67+
} else if (subject.length === 0) {
68+
return "empty subject";
69+
} else if (subject.length > 100) {
70+
return "invalid subject (must be <=100 chars)";
71+
}
72+
73+
return undefined;
74+
}
75+
76+
function run() {
77+
const eventData = JSON.parse(
78+
readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"),
79+
);
80+
const pullRequest = eventData.pull_request;
81+
82+
// console.log(eventData)
83+
84+
if (!pullRequest) {
85+
console.info("No pull request found in the context");
86+
return;
87+
}
88+
89+
const title = pullRequest.title;
90+
91+
const failReason = validateTitle(title);
92+
const msg = failReason
93+
? `
94+
Invalid pull request title: \`${title}\`
95+
96+
* Problem: ${failReason}
97+
* Expected format: \`type(scope): subject...\`
98+
* type: one of (${Array.from(types).join(", ")})
99+
* scope: optional, lowercase, <30 chars
100+
* subject: must be <100 chars
101+
* Hint: *close and re-open the PR* to re-trigger CI (after fixing the PR title).
102+
`
103+
: `Pull request title matches the expected format`;
104+
105+
if (process.env.GITHUB_STEP_SUMMARY) {
106+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, msg);
107+
}
108+
109+
if (failReason) {
110+
console.error(msg);
111+
process.exit(1);
112+
} else {
113+
console.info(msg);
114+
}
115+
}
116+
117+
function _test() {
118+
const tests = {
119+
" foo(scope): bar": 'type contains whitespace: " foo"',
120+
"build: update build process": undefined,
121+
"chore: update dependencies": undefined,
122+
"ci: configure CI/CD": undefined,
123+
"config: update configuration files": undefined,
124+
"deps: bump the aws-sdk group across 1 directory with 5 updates": undefined,
125+
"docs: update documentation": undefined,
126+
"feat(testing-sdk): add new feature": undefined,
127+
"feat(testing-sdk):": "empty subject",
128+
"feat foo):": 'type contains whitespace: "feat foo)"',
129+
"feat(foo)): sujet": 'invalid type "feat(foo))"',
130+
"feat(foo: sujet": 'invalid type "feat(foo"',
131+
"feat(Q Foo Bar): bar":
132+
'invalid scope (must be lowercase, ascii only): "Q Foo Bar"',
133+
"feat(testing-sdk): bar": undefined,
134+
"feat(testing-sdk): x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x ":
135+
"invalid subject (must be <=100 chars)",
136+
"feat: foo": '"feat" type must include a scope (example: "feat(testing-sdk)")',
137+
"fix: foo": '"fix" type must include a scope (example: "fix(testing-sdk)")',
138+
"fix(testing-sdk): resolve issue": undefined,
139+
"foo (scope): bar": 'type contains whitespace: "foo "',
140+
"invalid title": "missing colon (:) char",
141+
"perf: optimize performance": undefined,
142+
"refactor: improve code structure": undefined,
143+
"revert: feat: add new feature": undefined,
144+
"style: format code": undefined,
145+
"test: add new tests": undefined,
146+
"types: add type definitions": undefined,
147+
"Merge staging into feature/lambda-get-started": undefined,
148+
"feat(foo): fix the types":
149+
'invalid scope "foo" (valid scopes are testing-sdk, examples)',
150+
};
151+
152+
let passed = 0;
153+
let failed = 0;
154+
155+
for (const [title, expected] of Object.entries(tests)) {
156+
const result = validateTitle(title);
157+
if (result === expected) {
158+
console.log(`✅ Test passed for "${title}"`);
159+
passed++;
160+
} else {
161+
console.log(
162+
`❌ Test failed for "${title}" (expected "${expected}", got "${result}")`,
163+
);
164+
failed++;
165+
}
166+
}
167+
168+
console.log(`\n${passed} tests passed, ${failed} tests failed`);
169+
}
170+
171+
function main() {
172+
const mode = process.argv[2];
173+
174+
if (mode === "test") {
175+
_test();
176+
} else {
177+
run();
178+
}
179+
}
180+
181+
main();

0 commit comments

Comments
 (0)