mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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.
This commit is contained in:
@@ -45,6 +45,109 @@ jobs:
|
||||
# Some users have signed a corporate CLA with Coder so are exempt from signing our community one.
|
||||
allowlist: "coryb,aaronlehmann,dependabot*,blink-so*,blinkagent*"
|
||||
|
||||
title:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const { pull_request } = context.payload;
|
||||
const title = pull_request.title;
|
||||
const repo = { owner: context.repo.owner, repo: context.repo.repo };
|
||||
|
||||
const allowedTypes = [
|
||||
"feat", "fix", "docs", "style", "refactor",
|
||||
"perf", "test", "build", "ci", "chore", "revert",
|
||||
];
|
||||
const expectedFormat = `"type(scope): description" or "type: description"`;
|
||||
const guidelinesLink = `See: https://github.com/coder/coder/blob/main/docs/about/contributing/CONTRIBUTING.md#commit-messages`;
|
||||
const scopeHint = (type) =>
|
||||
`Use a broader scope or no scope (e.g., "${type}: ...") for cross-cutting changes.\n` +
|
||||
guidelinesLink;
|
||||
|
||||
console.log("Title: %s", title);
|
||||
|
||||
// Parse conventional commit format: type(scope)!: description
|
||||
const match = title.match(/^(\w+)(\(([^)]*)\))?(!)?\s*:\s*.+/);
|
||||
if (!match) {
|
||||
core.setFailed(
|
||||
`PR title does not match conventional commit format.\n` +
|
||||
`Expected: ${expectedFormat}\n` +
|
||||
`Allowed types: ${allowedTypes.join(", ")}\n` +
|
||||
guidelinesLink
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = match[1];
|
||||
const scope = match[3]; // undefined if no parentheses
|
||||
|
||||
// Validate type.
|
||||
if (!allowedTypes.includes(type)) {
|
||||
core.setFailed(
|
||||
`PR title has invalid type "${type}".\n` +
|
||||
`Expected: ${expectedFormat}\n` +
|
||||
`Allowed types: ${allowedTypes.join(", ")}\n` +
|
||||
guidelinesLink
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no scope, we're done.
|
||||
if (!scope) {
|
||||
console.log("No scope provided, title is valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Scope: %s", scope);
|
||||
|
||||
// Fetch changed files.
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
...repo,
|
||||
pull_number: pull_request.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const changedPaths = files.map(f => f.filename);
|
||||
console.log("Changed files: %d", changedPaths.length);
|
||||
|
||||
// Derive scope type from the changed files. The diff is the
|
||||
// source of truth: if files exist under the scope, the path
|
||||
// exists on the PR branch. No need for Contents API calls.
|
||||
const isDir = changedPaths.some(f => f.startsWith(scope + "/"));
|
||||
const isFile = changedPaths.some(f => f === scope);
|
||||
const isStem = changedPaths.some(f => f.startsWith(scope + "."));
|
||||
|
||||
if (!isDir && !isFile && !isStem) {
|
||||
core.setFailed(
|
||||
`PR title scope "${scope}" does not match any files changed in this PR.\n` +
|
||||
`Scopes must reference a path (directory or file stem) that contains changed files.\n` +
|
||||
scopeHint(type)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify all changed files fall under the scope.
|
||||
const outsideFiles = changedPaths.filter(f => {
|
||||
if (isDir && f.startsWith(scope + "/")) return false;
|
||||
if (f === scope) return false;
|
||||
if (isStem && f.startsWith(scope + ".")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (outsideFiles.length > 0) {
|
||||
const listed = outsideFiles.map(f => " - " + f).join("\n");
|
||||
core.setFailed(
|
||||
`PR title scope "${scope}" does not contain all changed files.\n` +
|
||||
`Files outside scope:\n${listed}\n\n` +
|
||||
scopeHint(type)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("PR title is valid.");
|
||||
|
||||
release-labels:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
Reference in New Issue
Block a user