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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ jobs:
cache: npm
cache-dependency-path: web/package-lock.json

- name: Verify commit policy
working-directory: .
run: make test-commit-policy

- name: Install dependencies
run: npm ci

Expand Down
79 changes: 79 additions & 0 deletions .github/workflows/commit-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: PR Commit Policy

on:
pull_request_target:
types: [opened, edited, synchronize, reopened, ready_for_review]

permissions:
pull-requests: write
statuses: write

concurrency:
group: commit-policy-${{ github.event.pull_request.number }}
# Finish posting the first reminder before another run can look for it.
cancel-in-progress: false

jobs:
check:
name: validate commit messages
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
# This privileged workflow reads API metadata only. Never check out or run PR code.
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
const repo = context.repo;
const marker = '<!-- ongrid-commit-policy -->';
const status = (state, description) => github.rest.repos.createCommitStatus({
...repo, sha: pr.head.sha, context: 'commit-policy', state, description,
target_url: `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`,
});
await status('pending', 'Checking commit messages.');
try {
const params = { ...repo, pull_number: pr.number };
const { data: current } = await github.rest.pulls.get(params);
if (current.head.sha !== pr.head.sha) return;
const commits = await github.paginate(github.rest.pulls.listCommits, { ...params, per_page: 100 });
const failures = [];
// ponytail: the API caps PR commits at 250; split larger PRs instead of silently skipping commits.
if (!commits.length || commits.length !== current.commits) {
failures.push('The complete commit list could not be checked. Split PRs larger than 250 commits and retry.');
}
const messages = [
{ label: 'PR title', message: current.title },
...commits.map(({ sha, commit }) => ({ label: sha.slice(0, 7), message: commit.message })),
];
for (const { label, message } of messages) {
const subject = message.split(/\r?\n/, 1)[0];
const reasons = [];
if (!/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_./-]+\))?!?: \S.*$/.test(subject)) {
reasons.push('use Conventional Commits, e.g. `fix: handle empty service names`');
}
// ponytail: this rejects Han characters, not every non-English language; use language review for semantic enforcement.
if (/\p{Script=Han}/u.test(message)) reasons.push('rewrite this text in English (Han characters are not allowed)');
if (reasons.length) failures.push(`- ${label}: ${reasons.join('; ')}.`);
}
const comments = await github.paginate(github.rest.issues.listComments, { ...repo, issue_number: pr.number, per_page: 100 });
const { data: latest } = await github.rest.pulls.get(params);
if (latest.head.sha !== current.head.sha || latest.base.ref !== current.base.ref ||
latest.base.sha !== current.base.sha || latest.title !== current.title) {
await status('failure', 'PR changed during validation. Re-run the workflow.');
core.setFailed('PR changed during validation. Re-run the workflow.');
return;
}
const details = failures.length ? failures.join('\n') : 'The PR title and all commit messages pass the format and Han-character checks.';
await core.summary.addRaw(`### Commit message policy\n\n${details}\n`).write();
const notified = comments.some(c => c.user?.login === 'github-actions[bot]' && c.body?.startsWith(marker));
if (!notified && failures.length) {
const checksUrl = `${context.serverUrl}/${repo.owner}/${repo.repo}/pull/${pr.number}/checks`;
const body = `${marker}\n### Commit message policy\n\nPlease write the PR title and every commit message in English using Conventional Commits, for example \`fix: handle empty service names\`. The automated check validates the format and rejects Han characters.\n\nSee [the latest PR checks](${checksUrl}) and open \`commit-policy\` for the current result and details.\n\nThis reminder is posted once per PR. Later failures and fixes only update the checks; this comment is not updated.`;
await github.rest.issues.createComment({ ...repo, issue_number: pr.number, body });
}
await status(failures.length ? 'failure' : 'success', failures.length ? 'Fix the PR title or commits; see the run summary.' : 'PR title and commit messages pass the policy.');
if (failures.length) core.setFailed('PR title or commit messages do not meet the policy.');
} catch (error) {
await status('failure', 'Commit policy could not be verified. Re-run the workflow.');
throw error;
}
33 changes: 33 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,36 @@ contributions.
- **Bugs / features** → open a GitHub issue.
- **Security vulnerabilities** → do **not** open a public issue. See
[SECURITY.md](SECURITY.md).

## Commit message checks

Write the PR title and every commit title and body in English. Titles must use
Conventional Commits, for example `fix(api): handle empty service names`. Supported types are
`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`,
and `revert`; scopes and breaking-change `!` markers are optional.

The `commit-policy` check validates the PR title and commit title format and
rejects Han characters in the PR title or anywhere in a commit message.
This is a deterministic check, not English language recognition.
Merge commits must also satisfy the policy; prefer rebasing your
feature branch. PRs above the API limit of 250 commits fail closed and must be
split. The first failed check posts one English reminder with the policy and a
link to the latest PR checks. The comment contains no commit-specific results
and is never updated. Later failures, fixes, and regressions only update the
check status and run summary, which lists the current violations.
Editing the PR title or target branch triggers another check. If the title,
head commit, or base branch changes during validation, the run fails and must
be rerun against the current PR state.

Squash merges use the PR title as the default commit title, so the PR title
must pass the same policy. This check cannot validate a custom commit message
typed into the merge dialog; maintainers must keep that message compliant.

Maintainers: after this workflow is merged into `main` and has run on a PR,
add `commit-policy` as a required status check in the branch rules for `main`,
retain existing checks, and restrict bypass access. The workflow publishes the
status on the PR head commit and runs only trusted base-branch code, including
for fork PRs. It does not enforce the language of human PR comments. To roll
back, remove this required check first, then revert the workflow change.

Run the policy regression checks locally with `make test-commit-policy`.
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -825,3 +825,7 @@ version-print: ## [release] 打印当前 VERSION(CI 消费用)
.PHONY: clean
clean: ## 清理构建产物
rm -rf $(BIN_DIR) coverage.out coverage.html

.PHONY: test-commit-policy
test-commit-policy: ## Verify PR commit policy and bot feedback
node scripts/test-commit-policy.cjs
86 changes: 86 additions & 0 deletions scripts/test-commit-policy.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const workflow = fs.readFileSync(path.join(__dirname, '../.github/workflows/commit-policy.yml'), 'utf8');
const script = workflow.split(' script: |\n')[1].replace(/^ /gm, '');
const run = new (Object.getPrototypeOf(async function () {}).constructor)('github', 'context', 'core', script);

async function check(messages, { previous, title = 'fix: handle empty input', eventTitle = title, latest, count = messages.length, stale = false, error = false } = {}) {
const states = [], comments = [], failed = [], summaries = [];
const pr = { number: 1, title, head: { sha: 'head' }, base: { ref: 'main', sha: 'base' }, commits: count };
let reads = 0;
const github = { rest: {
repos: { createCommitStatus: async value => states.push(value) },
pulls: { get: async () => ({ data: { ...pr, head: { sha: stale ? 'new' : 'head' }, ...(reads++ ? latest : {}) } }), listCommits: 'commits' },
issues: { listComments: 'comments', createComment: async c => comments.push(c), updateComment: async c => comments.push(c) },
}, paginate: async method => {
if (error) throw new Error('API unavailable');
return method === 'commits' ? messages.map((message, i) => ({ sha: String(i + 1).padStart(7, '0').padEnd(40, '0'), commit: { message } })) : (previous ? [previous] : []);
} };
const context = { payload: { pull_request: { ...pr, title: eventTitle } }, repo: { owner: 'test', repo: 'test' }, serverUrl: 'https://github.com', runId: 1 };
const summary = { addRaw(text) { return { write: async () => summaries.push(text) }; } };
try { await run(github, context, { setFailed: m => failed.push(m), summary }); }
catch (e) { if (!error) throw e; }
assert(states.every(s => s.sha === 'head' && s.context === 'commit-policy'));
return { state: states.at(-1).state, comments, failed, summary: summaries.join('') };
}

(async () => {
for (const message of ['fix: handle empty input', 'feat(api)!: change response\n\nBREAKING CHANGE: new shape', 'revert: remove cache', 'docs: improve reader’s guide']) {
const result = await check([message]);
assert.equal(result.state, 'success');
assert.equal(result.comments.length, 0);
}
for (const message of ['', 'fix: ', 'update docs', 'fix: 修复错误', 'fix: handle errors\n\n修复', 'fix: handle 𠀀', 'Merge branch main']) {
const result = await check([message]);
assert.equal(result.state, 'failure');
assert.equal(result.comments.length, 1);
assert.equal(result.failed.length, 1);
}
for (const title of ['修复登录错误', 'fix: 修复登录错误', 'update docs', '']) {
const result = await check(['fix: resolve login error'], { title });
assert.equal(result.state, 'failure', `Invalid PR title passed: ${title}`);
assert.match(result.summary, /PR title:/);
}
assert.match(workflow, /types: \[[^\]\n]*\bedited\b[^\]\n]*\]/, 'PR title and base edits must trigger the check');
assert.match(workflow, /cancel-in-progress: false/, 'Serialize comment creation across pushes');
for (const latest of [
{ title: 'fix: 修复登录错误' },
{ base: { ref: 'release', sha: 'base' } },
{ base: { ref: 'main', sha: 'new-base' } },
{ head: { sha: 'new-head' } },
]) {
const result = await check(['fix: valid'], { latest });
assert.equal(result.state, 'failure', 'Changed PR metadata must not receive a stale success');
assert.equal(result.failed.length, 1);
assert.equal(result.comments.length, 0);
}
assert.equal((await check(['fix: valid'], { title: 'feat(api)!: change response', eventTitle: '旧标题' })).state, 'success');
assert.equal((await check(['fix: valid', 'bad'])).state, 'failure');
assert.equal((await check(Array(250).fill('fix: valid'), { count: 251 })).state, 'failure');
assert.equal((await check([])).state, 'failure');
assert.equal((await check(['fix: valid'], { error: true })).state, 'failure');
assert.equal((await check(['fix: valid'], { stale: true })).state, 'pending');
const firstFailure = await check(['fix: 修复错误']);
assert.equal(firstFailure.comments.length, 1);
assert.match(firstFailure.comments[0].body, /https:\/\/github.com\/test\/test\/pull\/1\/checks/);
assert.doesNotMatch(firstFailure.comments[0].body, /0000001|修复错误|actions\/runs/);
assert.match(firstFailure.summary, /0000001:.*Han characters/);
const previous = { id: 7, user: { login: 'github-actions[bot]' }, body: firstFailure.comments[0].body };
const continuedFailure = await check(['fix: valid', 'invalid format'], { previous });
assert.equal(continuedFailure.state, 'failure');
assert.equal(continuedFailure.comments.length, 0, 'Repeated failures must not create or edit comments');
assert.match(continuedFailure.summary, /0000002:.*Conventional Commits/);
assert.doesNotMatch(continuedFailure.summary, /0000001:|Han characters/);
const fixed = await check(['fix: valid'], { previous });
assert.equal(fixed.state, 'success');
assert.equal(fixed.comments.length, 0, 'Fixes must not create or edit comments');
assert.match(fixed.summary, /PR title and all commit messages pass/);
const relapse = await check(['fix: valid'], { previous, title: 'fix: 修复错误' });
assert.equal(relapse.state, 'failure');
assert.equal(relapse.comments.length, 0, 'A regression after a fix must not repeat the reminder');
assert.match(relapse.summary, /PR title:.*Han characters/);
assert.equal((await check(['bad'], { previous: { ...previous, user: { login: 'someone' } } })).comments[0].comment_id, undefined);
assert.equal((await check(['bad @everyone <script>'])).comments[0].body.includes('@everyone'), false);
console.log('Commit policy checks passed.');
})();