Skip to content

Commit aa6f301

Browse files
authored
ci: add conventional commit PR title linting (#23096)
Restore PR title validation that was removed in 828f33a when cdr-bot was expected to handle it. That bot has since been disabled. The new title job in contrib.yaml validates: - Conventional commit format (type(scope): description) - Type from the same set used by release notes generation - Scope validity derived from the changed files in the PR diff - All changed files fall under the declared scope Uses actions/github-script (no third-party marketplace actions). Also fixes feat(api) examples across docs (no api folder exists) and consolidates commit rules into CONTRIBUTING.md as the single source of truth.
1 parent ae8bed4 commit aa6f301

6 files changed

Lines changed: 123 additions & 22 deletions

File tree

.claude/docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Coder emphasizes clear error handling, with specific patterns required:
113113

114114
All tests should run in parallel using `t.Parallel()` to ensure efficient testing and expose potential race conditions. The codebase is rigorously linted with golangci-lint to maintain consistent code quality.
115115

116-
Git contributions follow a standard format with commit messages structured as `type: <message>`, where type is one of `feat`, `fix`, or `chore`.
116+
Git contributions follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
117117

118118
## Development Workflow
119119

.claude/docs/PR_STYLE_GUIDE.md

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,13 @@ This guide documents the PR description style used in the Coder repository, base
44

55
## PR Title Format
66

7-
Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/) format:
7+
Format: `type(scope): description`. See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
88

9-
```text
10-
type(scope): brief description
11-
```
12-
13-
**Common types:**
14-
15-
- `feat`: New features
16-
- `fix`: Bug fixes
17-
- `refactor`: Code refactoring without behavior change
18-
- `perf`: Performance improvements
19-
- `docs`: Documentation changes
20-
- `chore`: Dependency updates, tooling changes
9+
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
10+
- Scopes must be a real path (directory or file stem) containing all changed files
11+
- Omit scope if changes span multiple top-level directories
2112

22-
**Examples:**
13+
Examples:
2314

2415
- `feat: add tracing to aibridge`
2516
- `fix: move contexts to appropriate locations`

.claude/docs/WORKFLOWS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,11 @@ Then make your changes and push normally. Don't use `git push --force` unless th
136136

137137
## Commit Style
138138

139-
- Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)
140-
- Format: `type(scope): message`
141-
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
139+
Format: `type(scope): message`. See [CONTRIBUTING.md](docs/about/contributing/CONTRIBUTING.md#commit-messages) for full rules. PR titles are linted in CI.
140+
141+
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`
142+
- Scopes must be a real path (directory or file stem) containing all changed files
143+
- Omit scope if changes span multiple top-level directories
142144
- Keep message titles concise (~70 characters)
143145
- Use imperative, present tense in commit titles
144146

.github/workflows/contrib.yaml

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,109 @@ jobs:
4545
# Some users have signed a corporate CLA with Coder so are exempt from signing our community one.
4646
allowlist: "coryb,aaronlehmann,dependabot*,blink-so*,blinkagent*"
4747

48+
title:
49+
runs-on: ubuntu-latest
50+
if: ${{ github.event_name == 'pull_request_target' }}
51+
steps:
52+
- name: Validate PR title
53+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
54+
with:
55+
script: |
56+
const { pull_request } = context.payload;
57+
const title = pull_request.title;
58+
const repo = { owner: context.repo.owner, repo: context.repo.repo };
59+
60+
const allowedTypes = [
61+
"feat", "fix", "docs", "style", "refactor",
62+
"perf", "test", "build", "ci", "chore", "revert",
63+
];
64+
const expectedFormat = `"type(scope): description" or "type: description"`;
65+
const guidelinesLink = `See: https://github.com/coder/coder/blob/main/docs/about/contributing/CONTRIBUTING.md#commit-messages`;
66+
const scopeHint = (type) =>
67+
`Use a broader scope or no scope (e.g., "${type}: ...") for cross-cutting changes.\n` +
68+
guidelinesLink;
69+
70+
console.log("Title: %s", title);
71+
72+
// Parse conventional commit format: type(scope)!: description
73+
const match = title.match(/^(\w+)(\(([^)]*)\))?(!)?\s*:\s*.+/);
74+
if (!match) {
75+
core.setFailed(
76+
`PR title does not match conventional commit format.\n` +
77+
`Expected: ${expectedFormat}\n` +
78+
`Allowed types: ${allowedTypes.join(", ")}\n` +
79+
guidelinesLink
80+
);
81+
return;
82+
}
83+
84+
const type = match[1];
85+
const scope = match[3]; // undefined if no parentheses
86+
87+
// Validate type.
88+
if (!allowedTypes.includes(type)) {
89+
core.setFailed(
90+
`PR title has invalid type "${type}".\n` +
91+
`Expected: ${expectedFormat}\n` +
92+
`Allowed types: ${allowedTypes.join(", ")}\n` +
93+
guidelinesLink
94+
);
95+
return;
96+
}
97+
98+
// If no scope, we're done.
99+
if (!scope) {
100+
console.log("No scope provided, title is valid.");
101+
return;
102+
}
103+
104+
console.log("Scope: %s", scope);
105+
106+
// Fetch changed files.
107+
const files = await github.paginate(github.rest.pulls.listFiles, {
108+
...repo,
109+
pull_number: pull_request.number,
110+
per_page: 100,
111+
});
112+
const changedPaths = files.map(f => f.filename);
113+
console.log("Changed files: %d", changedPaths.length);
114+
115+
// Derive scope type from the changed files. The diff is the
116+
// source of truth: if files exist under the scope, the path
117+
// exists on the PR branch. No need for Contents API calls.
118+
const isDir = changedPaths.some(f => f.startsWith(scope + "/"));
119+
const isFile = changedPaths.some(f => f === scope);
120+
const isStem = changedPaths.some(f => f.startsWith(scope + "."));
121+
122+
if (!isDir && !isFile && !isStem) {
123+
core.setFailed(
124+
`PR title scope "${scope}" does not match any files changed in this PR.\n` +
125+
`Scopes must reference a path (directory or file stem) that contains changed files.\n` +
126+
scopeHint(type)
127+
);
128+
return;
129+
}
130+
131+
// Verify all changed files fall under the scope.
132+
const outsideFiles = changedPaths.filter(f => {
133+
if (isDir && f.startsWith(scope + "/")) return false;
134+
if (f === scope) return false;
135+
if (isStem && f.startsWith(scope + ".")) return false;
136+
return true;
137+
});
138+
139+
if (outsideFiles.length > 0) {
140+
const listed = outsideFiles.map(f => " - " + f).join("\n");
141+
core.setFailed(
142+
`PR title scope "${scope}" does not contain all changed files.\n` +
143+
`Files outside scope:\n${listed}\n\n` +
144+
scopeHint(type)
145+
);
146+
return;
147+
}
148+
149+
console.log("PR title is valid.");
150+
48151
release-labels:
49152
runs-on: ubuntu-latest
50153
permissions:

docs/about/contributing/CONTRIBUTING.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,13 @@ characters long (no more than 72).
247247

248248
Examples:
249249

250-
- Good: `feat(api): add feature X`
251-
- Bad: `feat(api): added feature X` (past tense)
250+
- Good: `feat(coderd): add feature X`
251+
- Bad: `feat(coderd): added feature X` (past tense)
252+
253+
Scopes must reference a real path in the repository (a directory or file stem)
254+
and must contain all changed files. For example, use `coderd/database` if all
255+
changes are within that directory. If changes span multiple top-level
256+
directories, omit the scope.
252257

253258
A good rule of thumb for writing good commit messages is to recite:
254259
[If applied, this commit will ...](https://reflectoring.io/meaningful-commit-messages/).
@@ -263,7 +268,7 @@ to use the original commit title instead of the PR title.
263268
Breaking changes can be triggered in two ways:
264269
265270
- Add `!` to the commit message title, e.g.
266-
`feat(api)!: remove deprecated endpoint /test`
271+
`feat(coderd)!: remove deprecated endpoint /test`
267272
- Add the
268273
[`release/breaking`](https://github.com/coder/coder/issues?q=sort%3Aupdated-desc+label%3Arelease%2Fbreaking)
269274
label to a PR that has, or will be, merged into `main`.

scripts/release.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ new patch version will be created.
1919
2020
To mark a release as containing breaking changes, the commit title should
2121
either contain a known prefix with an exclamation mark ("feat!:",
22-
"feat(api)!:") or the PR that was merged can be tagged with the
22+
"feat(coderd)!:") or the PR that was merged can be tagged with the
2323
"release/breaking" label.
2424
2525
GitHub labels that affect release notes:

0 commit comments

Comments
 (0)