Skip to content

Commit 5d29be8

Browse files
authored
ci: lint-commit messages (#90)
1 parent 5eb0178 commit 5d29be8

File tree

2 files changed

+194
-0
lines changed

2 files changed

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

0 commit comments

Comments
 (0)