From a542a4a4903d8c64a14986483ed465bbfb984873 Mon Sep 17 00:00:00 2001 From: Shihao Xing <1480432576@qq.com> Date: Mon, 13 Jul 2026 13:17:27 +0800 Subject: [PATCH 1/4] ci: add contributor trust gate --- .github/PULL_REQUEST_TEMPLATE.md | 36 ++++-- .github/scripts/contributor-trust/rules.mjs | 82 +++++++++++++ .../scripts/contributor-trust/rules.test.mjs | 58 +++++++++ .github/scripts/contributor-trust/run.mjs | 113 ++++++++++++++++++ .github/workflows/contributor-trust.yml | 54 +++++++++ 5 files changed, 332 insertions(+), 11 deletions(-) create mode 100644 .github/scripts/contributor-trust/rules.mjs create mode 100644 .github/scripts/contributor-trust/rules.test.mjs create mode 100644 .github/scripts/contributor-trust/run.mjs create mode 100644 .github/workflows/contributor-trust.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 984c3c5..b484fdd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,11 +1,25 @@ -Checklist(清单): - - - -- [ ] Labels -- [ ] Assignees -- [ ] Reviewers - - - -Closes #XXXXX +## 变更说明 + + + +## 关联任务 + + + +Closes #XXXXX + +## 验证方式 + + + +```text +命令: +结果: +``` + +## 贡献声明 + +- [ ] 我已阅读并理解本次改动,能够回答维护者的问题 +- [ ] 我已实际运行上方验证步骤,并如实记录结果 + +AI 使用情况: diff --git a/.github/scripts/contributor-trust/rules.mjs b/.github/scripts/contributor-trust/rules.mjs new file mode 100644 index 0000000..8bf5209 --- /dev/null +++ b/.github/scripts/contributor-trust/rules.mjs @@ -0,0 +1,82 @@ +const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + +function cleanText(value = '') { + return value + .replace(//g, '') + .replace(/```[^]*?```/g, match => match.replace(/```\w*/g, '')) + .trim(); +} + +function section(body, names) { + const lines = body.split(/\r?\n/); + const heading = new RegExp(`^##\\s+(?:${names.join('|')})\\s*$`, 'i'); + const start = lines.findIndex(line => heading.test(line.trim())); + if (start < 0) return ''; + + const content = []; + for (let index = start + 1; index < lines.length; index += 1) { + if (/^##\s+/.test(lines[index].trim())) break; + content.push(lines[index]); + } + return cleanText(content.join('\n')); +} + +function hasChecked(body, phrase) { + const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`-\\s*\\[[xX]\\]\\s*${escaped}`, 'u').test(body); +} + +export function evaluatePullRequest({ + body = '', + draft = false, + authorAssociation = 'NONE', + authorType = 'User', + changedFiles = 0, + additions = 0, +}) { + if (draft) { + return { passed: true, skipped: true, missing: [], reviewReasons: [] }; + } + + const trusted = trustedAssociations.has(authorAssociation); + const summary = section(body, ['变更说明', 'Summary']); + const verification = section(body, ['验证方式', 'Verification']); + const missing = []; + + if (!trusted) { + if (summary.length < 20) missing.push('补充不少于 20 字的变更说明'); + if ( + verification.length < 12 || + /^(?:未运行|未测试|none|not run|n\/?a|无)[。.!\s]*$/iu.test(verification) + ) { + missing.push('填写实际运行的验证命令、场景和结果'); + } + if (!/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#\d+/iu.test(body)) { + missing.push('使用 Closes/Fixes/Resolves #编号 关联任务'); + } + if (!hasChecked(body, '我已阅读并理解本次改动,能够回答维护者的问题')) { + missing.push('勾选“已阅读并理解本次改动”责任声明'); + } + if (!hasChecked(body, '我已实际运行上方验证步骤,并如实记录结果')) { + missing.push('勾选“已实际运行验证步骤”责任声明'); + } + + const disclosure = body.match(/^AI 使用情况:\s*(.+)$/imu)?.[1]?.trim() ?? ''; + if (!disclosure || /^(?:未填写|待填写|todo|n\/?a)$/iu.test(disclosure)) { + missing.push('如实填写 AI 使用情况及人工复核内容'); + } + } + + const reviewReasons = []; + if (authorType === 'Bot') reviewReasons.push('机器人账号提交'); + if (changedFiles > 50) reviewReasons.push(`改动文件较多(${changedFiles})`); + if (additions > 1500) reviewReasons.push(`新增代码量较大(${additions} 行)`); + + return { + passed: missing.length === 0, + skipped: false, + trusted, + missing, + reviewReasons, + }; +} diff --git a/.github/scripts/contributor-trust/rules.test.mjs b/.github/scripts/contributor-trust/rules.test.mjs new file mode 100644 index 0000000..9bf0df2 --- /dev/null +++ b/.github/scripts/contributor-trust/rules.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { evaluatePullRequest } from './rules.mjs'; + +const completeBody = `## 变更说明 + +修复奖励任务页面的状态同步问题,并补充回归覆盖以避免同类问题再次出现。 + +## 关联任务 + +Closes #89 + +## 验证方式 + +运行 pnpm test,全部测试通过;随后执行 pnpm build,构建成功。 + +## 贡献声明 + +- [x] 我已阅读并理解本次改动,能够回答维护者的问题 +- [x] 我已实际运行上方验证步骤,并如实记录结果 + +AI 使用情况:使用了代码补全,已逐行复核并运行测试。`; + +test('passes a complete external contribution', () => { + const result = evaluatePullRequest({ body: completeBody }); + assert.equal(result.passed, true); + assert.deepEqual(result.missing, []); +}); + +test('lists every missing proof for an empty template', () => { + const result = evaluatePullRequest({ body: 'Closes #XXXXX' }); + assert.equal(result.passed, false); + assert.equal(result.missing.length, 6); +}); + +test('trusted maintainers bypass contributor proof fields', () => { + const result = evaluatePullRequest({ body: '', authorAssociation: 'MEMBER' }); + assert.equal(result.passed, true); + assert.equal(result.trusted, true); +}); + +test('draft pull requests are skipped until ready', () => { + const result = evaluatePullRequest({ body: '', draft: true }); + assert.equal(result.passed, true); + assert.equal(result.skipped, true); +}); + +test('flags bots and unusually large changes for manual review', () => { + const result = evaluatePullRequest({ + body: completeBody, + authorType: 'Bot', + changedFiles: 51, + additions: 1501, + }); + assert.equal(result.passed, true); + assert.equal(result.reviewReasons.length, 3); +}); diff --git a/.github/scripts/contributor-trust/run.mjs b/.github/scripts/contributor-trust/run.mjs new file mode 100644 index 0000000..71615c7 --- /dev/null +++ b/.github/scripts/contributor-trust/run.mjs @@ -0,0 +1,113 @@ +import { appendFile } from 'node:fs/promises'; + +import { evaluatePullRequest } from './rules.mjs'; + +const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? '').split('/'); +const pullNumber = Number(process.env.TRUST_PR_NUMBER); +const token = process.env.GITHUB_TOKEN; +const marker = ''; + +if (!owner || !repo || !Number.isInteger(pullNumber) || !token) { + throw new Error('GITHUB_REPOSITORY, TRUST_PR_NUMBER and GITHUB_TOKEN are required'); +} + +async function api(path, options = {}, allowNotFound = false) { + const response = await fetch(`https://api.github.com${path}`, { + ...options, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...options.headers, + }, + }); + if (allowNotFound && response.status === 404) return null; + if (!response.ok) throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status}`); + return response.status === 204 ? null : response.json(); +} + +async function ensureLabel(name, color, description) { + const encoded = encodeURIComponent(name); + const existing = await api(`/repos/${owner}/${repo}/labels/${encoded}`, {}, true); + if (existing) return; + await api(`/repos/${owner}/${repo}/labels`, { + method: 'POST', + body: JSON.stringify({ name, color, description }), + }); +} + +async function setLabel(name, enabled) { + const encoded = encodeURIComponent(name); + if (enabled) { + await api(`/repos/${owner}/${repo}/issues/${pullNumber}/labels`, { + method: 'POST', + body: JSON.stringify({ labels: [name] }), + }); + } else { + await api( + `/repos/${owner}/${repo}/issues/${pullNumber}/labels/${encoded}`, + { + method: 'DELETE', + }, + true, + ); + } +} + +async function syncComment(body, createWhenMissing) { + const comments = await api(`/repos/${owner}/${repo}/issues/${pullNumber}/comments?per_page=100`); + const existing = comments.find(comment => comment.body?.includes(marker)); + if (existing) { + await api(`/repos/${owner}/${repo}/issues/comments/${existing.id}`, { + method: 'PATCH', + body: JSON.stringify({ body }), + }); + } else if (createWhenMissing) { + await api(`/repos/${owner}/${repo}/issues/${pullNumber}/comments`, { + method: 'POST', + body: JSON.stringify({ body }), + }); + } +} + +const pull = await api(`/repos/${owner}/${repo}/pulls/${pullNumber}`); +const result = evaluatePullRequest({ + body: pull.body ?? '', + draft: pull.draft, + authorAssociation: pull.author_association, + authorType: pull.user?.type, + changedFiles: pull.changed_files, + additions: pull.additions, +}); + +await ensureLabel('contributor-check:passed', '1f883d', '贡献信息门禁已通过'); +await ensureLabel('needs-contributor-info', 'd1242f', '需要补充可验证的贡献信息'); +await ensureLabel('needs-maintainer-review', 'bf8700', '需要维护者人工复核'); + +await setLabel('contributor-check:passed', result.passed && !result.skipped); +await setLabel('needs-contributor-info', !result.passed); +await setLabel('needs-maintainer-review', result.reviewReasons.length > 0); + +const missingLines = result.missing.map(item => `- [ ] ${item}`).join('\n'); +const reviewLines = result.reviewReasons.map(item => `- ${item}`).join('\n'); +const comment = `${marker} +## 贡献信息检查 + +${result.passed ? '已通过自动检查。维护者仍会审阅实现质量和实际行为。' : `请补充以下信息后再次更新 PR:\n\n${missingLines}`} +${reviewLines ? `\n### 人工复核提示\n\n${reviewLines}` : ''} + +本检查不禁止使用 AI,但提交者必须理解改动、披露使用情况并提供真实验证证据。`; + +await syncComment(comment, !result.passed || result.reviewReasons.length > 0); + +if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile( + process.env.GITHUB_STEP_SUMMARY, + `## Contributor trust gate\n\n- PR: #${pullNumber}\n- Result: ${result.skipped ? 'skipped (draft)' : result.passed ? 'passed' : 'failed'}\n- Missing: ${result.missing.length}\n- Manual review reasons: ${result.reviewReasons.length}\n`, + ); +} + +if (!result.passed) { + process.exitCode = 1; + console.error(`Contributor information is incomplete: ${result.missing.join('; ')}`); +} diff --git a/.github/workflows/contributor-trust.yml b/.github/workflows/contributor-trust.yml new file mode 100644 index 0000000..5d72a5a --- /dev/null +++ b/.github/workflows/contributor-trust.yml @@ -0,0 +1,54 @@ +name: Contributor Trust Gate + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize, ready_for_review] + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to recheck + required: true + type: number + +concurrency: + group: contributor-trust-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: true + +jobs: + contributor-trust: + name: Verify contributor evidence + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: read + steps: + - name: Resolve pull request number + id: pr + env: + EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} + INPUT_PR_NUMBER: ${{ inputs.pr_number }} + run: | + pr_number="${EVENT_PR_NUMBER:-$INPUT_PR_NUMBER}" + test -n "$pr_number" + echo "number=$pr_number" >> "$GITHUB_OUTPUT" + + - name: Check out trusted base branch + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.2 + with: + ref: ${{ github.event.pull_request.base.sha || github.event.repository.default_branch }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.3.0 + with: + node-version: 24 + + - name: Test trust rules + run: node --test .github/scripts/contributor-trust/rules.test.mjs + + - name: Evaluate pull request + env: + GITHUB_TOKEN: ${{ github.token }} + TRUST_PR_NUMBER: ${{ steps.pr.outputs.number }} + run: node .github/scripts/contributor-trust/run.mjs From 8cad16890fcbd5cecf47759ed7d2de0e0851c20e Mon Sep 17 00:00:00 2001 From: Shihao Xing <1480432576@qq.com> Date: Mon, 13 Jul 2026 13:23:01 +0800 Subject: [PATCH 2/4] ci: require approval before reward work --- .github/ISSUE_TEMPLATE/reward-task.yml | 15 ++++++++- .github/scripts/contributor-trust/rules.mjs | 16 ++++++++++ .../scripts/contributor-trust/rules.test.mjs | 31 ++++++++++++++++++- .github/scripts/contributor-trust/run.mjs | 23 +++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/reward-task.yml b/.github/ISSUE_TEMPLATE/reward-task.yml index f6ebf76..9ca7089 100644 --- a/.github/ISSUE_TEMPLATE/reward-task.yml +++ b/.github/ISSUE_TEMPLATE/reward-task.yml @@ -6,7 +6,20 @@ labels: body: - type: markdown attributes: - value: This form is created from https://github.com/idea2app/GitHub-reward + value: | + This form is created from https://github.com/idea2app/GitHub-reward + + Please discuss the task in GitHub Discussions first. A maintainer should + confirm the scope before a reward issue is opened and assigned. + + - type: input + id: discussion + attributes: + label: Approved discussion + description: URL of the GitHub Discussion where a maintainer confirmed the task + placeholder: https://github.com/orgs/Open-Source-Bazaar/discussions/123 + validations: + required: true - type: textarea id: description diff --git a/.github/scripts/contributor-trust/rules.mjs b/.github/scripts/contributor-trust/rules.mjs index 8bf5209..3f17fb5 100644 --- a/.github/scripts/contributor-trust/rules.mjs +++ b/.github/scripts/contributor-trust/rules.mjs @@ -33,6 +33,7 @@ export function evaluatePullRequest({ authorType = 'User', changedFiles = 0, additions = 0, + rewardAuthorization, }) { if (draft) { return { passed: true, skipped: true, missing: [], reviewReasons: [] }; @@ -54,6 +55,11 @@ export function evaluatePullRequest({ if (!/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#\d+/iu.test(body)) { missing.push('使用 Closes/Fixes/Resolves #编号 关联任务'); } + if (rewardAuthorization?.required && !rewardAuthorization.authorized) { + missing.push( + `奖励任务 #${rewardAuthorization.issueNumbers.join(', #')} 需要维护者先指派给提交者或添加 implementation-approved 标签`, + ); + } if (!hasChecked(body, '我已阅读并理解本次改动,能够回答维护者的问题')) { missing.push('勾选“已阅读并理解本次改动”责任声明'); } @@ -80,3 +86,13 @@ export function evaluatePullRequest({ reviewReasons, }; } + +export function linkedIssueNumbers(body = '') { + return [ + ...new Set( + [...body.matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/giu)].map(match => + Number(match[1]), + ), + ), + ]; +} diff --git a/.github/scripts/contributor-trust/rules.test.mjs b/.github/scripts/contributor-trust/rules.test.mjs index 9bf0df2..cc48cf0 100644 --- a/.github/scripts/contributor-trust/rules.test.mjs +++ b/.github/scripts/contributor-trust/rules.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { evaluatePullRequest } from './rules.mjs'; +import { evaluatePullRequest, linkedIssueNumbers } from './rules.mjs'; const completeBody = `## 变更说明 @@ -56,3 +56,32 @@ test('flags bots and unusually large changes for manual review', () => { assert.equal(result.passed, true); assert.equal(result.reviewReasons.length, 3); }); + +test('requires maintainer authorization for reward work', () => { + const result = evaluatePullRequest({ + body: completeBody, + rewardAuthorization: { + required: true, + authorized: false, + issueNumbers: [89], + }, + }); + assert.equal(result.passed, false); + assert.match(result.missing.join('\n'), /implementation-approved/); +}); + +test('accepts reward work assigned or approved by a maintainer', () => { + const result = evaluatePullRequest({ + body: completeBody, + rewardAuthorization: { + required: true, + authorized: true, + issueNumbers: [89], + }, + }); + assert.equal(result.passed, true); +}); + +test('extracts unique closing issue references', () => { + assert.deepEqual(linkedIssueNumbers('Closes #89, fixes #90 and resolves #89'), [89, 90]); +}); diff --git a/.github/scripts/contributor-trust/run.mjs b/.github/scripts/contributor-trust/run.mjs index 71615c7..25e90b4 100644 --- a/.github/scripts/contributor-trust/run.mjs +++ b/.github/scripts/contributor-trust/run.mjs @@ -1,6 +1,6 @@ import { appendFile } from 'node:fs/promises'; -import { evaluatePullRequest } from './rules.mjs'; +import { evaluatePullRequest, linkedIssueNumbers } from './rules.mjs'; const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? '').split('/'); const pullNumber = Number(process.env.TRUST_PR_NUMBER); @@ -71,6 +71,25 @@ async function syncComment(body, createWhenMissing) { } const pull = await api(`/repos/${owner}/${repo}/pulls/${pullNumber}`); +const linkedIssues = await Promise.all( + linkedIssueNumbers(pull.body ?? '').map(number => + api(`/repos/${owner}/${repo}/issues/${number}`, {}, true), + ), +); +const rewardIssues = linkedIssues.filter(issue => + issue?.labels?.some(label => label.name === 'reward'), +); +const rewardAuthorization = rewardIssues.length + ? { + required: true, + issueNumbers: rewardIssues.map(issue => issue.number), + authorized: rewardIssues.every( + issue => + issue.assignees?.some(assignee => assignee.login === pull.user?.login) || + issue.labels?.some(label => label.name === 'implementation-approved'), + ), + } + : undefined; const result = evaluatePullRequest({ body: pull.body ?? '', draft: pull.draft, @@ -78,11 +97,13 @@ const result = evaluatePullRequest({ authorType: pull.user?.type, changedFiles: pull.changed_files, additions: pull.additions, + rewardAuthorization, }); await ensureLabel('contributor-check:passed', '1f883d', '贡献信息门禁已通过'); await ensureLabel('needs-contributor-info', 'd1242f', '需要补充可验证的贡献信息'); await ensureLabel('needs-maintainer-review', 'bf8700', '需要维护者人工复核'); +await ensureLabel('implementation-approved', '8250df', '维护者已批准贡献者实现该奖励任务'); await setLabel('contributor-check:passed', result.passed && !result.skipped); await setLabel('needs-contributor-info', !result.passed); From 0265bfe18f02dce19ce5993d7cd7e6e9c928f3b5 Mon Sep 17 00:00:00 2001 From: Nerokzy <1480432576@qq.com> Date: Wed, 15 Jul 2026 22:31:19 +0800 Subject: [PATCH 3/4] refactor: use reusable contributor trust action --- .github/ISSUE_TEMPLATE/reward-task.yml | 15 +- .github/PULL_REQUEST_TEMPLATE.md | 36 ++--- .github/scripts/contributor-trust/rules.mjs | 98 ------------- .../scripts/contributor-trust/rules.test.mjs | 87 ------------ .github/scripts/contributor-trust/run.mjs | 134 ------------------ .github/workflows/contributor-trust.yml | 66 ++++----- 6 files changed, 37 insertions(+), 399 deletions(-) delete mode 100644 .github/scripts/contributor-trust/rules.mjs delete mode 100644 .github/scripts/contributor-trust/rules.test.mjs delete mode 100644 .github/scripts/contributor-trust/run.mjs diff --git a/.github/ISSUE_TEMPLATE/reward-task.yml b/.github/ISSUE_TEMPLATE/reward-task.yml index 9ca7089..f6ebf76 100644 --- a/.github/ISSUE_TEMPLATE/reward-task.yml +++ b/.github/ISSUE_TEMPLATE/reward-task.yml @@ -6,20 +6,7 @@ labels: body: - type: markdown attributes: - value: | - This form is created from https://github.com/idea2app/GitHub-reward - - Please discuss the task in GitHub Discussions first. A maintainer should - confirm the scope before a reward issue is opened and assigned. - - - type: input - id: discussion - attributes: - label: Approved discussion - description: URL of the GitHub Discussion where a maintainer confirmed the task - placeholder: https://github.com/orgs/Open-Source-Bazaar/discussions/123 - validations: - required: true + value: This form is created from https://github.com/idea2app/GitHub-reward - type: textarea id: description diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b484fdd..984c3c5 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,25 +1,11 @@ -## 变更说明 - - - -## 关联任务 - - - -Closes #XXXXX - -## 验证方式 - - - -```text -命令: -结果: -``` - -## 贡献声明 - -- [ ] 我已阅读并理解本次改动,能够回答维护者的问题 -- [ ] 我已实际运行上方验证步骤,并如实记录结果 - -AI 使用情况: +Checklist(清单): + + + +- [ ] Labels +- [ ] Assignees +- [ ] Reviewers + + + +Closes #XXXXX diff --git a/.github/scripts/contributor-trust/rules.mjs b/.github/scripts/contributor-trust/rules.mjs deleted file mode 100644 index 3f17fb5..0000000 --- a/.github/scripts/contributor-trust/rules.mjs +++ /dev/null @@ -1,98 +0,0 @@ -const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); - -function cleanText(value = '') { - return value - .replace(//g, '') - .replace(/```[^]*?```/g, match => match.replace(/```\w*/g, '')) - .trim(); -} - -function section(body, names) { - const lines = body.split(/\r?\n/); - const heading = new RegExp(`^##\\s+(?:${names.join('|')})\\s*$`, 'i'); - const start = lines.findIndex(line => heading.test(line.trim())); - if (start < 0) return ''; - - const content = []; - for (let index = start + 1; index < lines.length; index += 1) { - if (/^##\s+/.test(lines[index].trim())) break; - content.push(lines[index]); - } - return cleanText(content.join('\n')); -} - -function hasChecked(body, phrase) { - const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`-\\s*\\[[xX]\\]\\s*${escaped}`, 'u').test(body); -} - -export function evaluatePullRequest({ - body = '', - draft = false, - authorAssociation = 'NONE', - authorType = 'User', - changedFiles = 0, - additions = 0, - rewardAuthorization, -}) { - if (draft) { - return { passed: true, skipped: true, missing: [], reviewReasons: [] }; - } - - const trusted = trustedAssociations.has(authorAssociation); - const summary = section(body, ['变更说明', 'Summary']); - const verification = section(body, ['验证方式', 'Verification']); - const missing = []; - - if (!trusted) { - if (summary.length < 20) missing.push('补充不少于 20 字的变更说明'); - if ( - verification.length < 12 || - /^(?:未运行|未测试|none|not run|n\/?a|无)[。.!\s]*$/iu.test(verification) - ) { - missing.push('填写实际运行的验证命令、场景和结果'); - } - if (!/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#\d+/iu.test(body)) { - missing.push('使用 Closes/Fixes/Resolves #编号 关联任务'); - } - if (rewardAuthorization?.required && !rewardAuthorization.authorized) { - missing.push( - `奖励任务 #${rewardAuthorization.issueNumbers.join(', #')} 需要维护者先指派给提交者或添加 implementation-approved 标签`, - ); - } - if (!hasChecked(body, '我已阅读并理解本次改动,能够回答维护者的问题')) { - missing.push('勾选“已阅读并理解本次改动”责任声明'); - } - if (!hasChecked(body, '我已实际运行上方验证步骤,并如实记录结果')) { - missing.push('勾选“已实际运行验证步骤”责任声明'); - } - - const disclosure = body.match(/^AI 使用情况:\s*(.+)$/imu)?.[1]?.trim() ?? ''; - if (!disclosure || /^(?:未填写|待填写|todo|n\/?a)$/iu.test(disclosure)) { - missing.push('如实填写 AI 使用情况及人工复核内容'); - } - } - - const reviewReasons = []; - if (authorType === 'Bot') reviewReasons.push('机器人账号提交'); - if (changedFiles > 50) reviewReasons.push(`改动文件较多(${changedFiles})`); - if (additions > 1500) reviewReasons.push(`新增代码量较大(${additions} 行)`); - - return { - passed: missing.length === 0, - skipped: false, - trusted, - missing, - reviewReasons, - }; -} - -export function linkedIssueNumbers(body = '') { - return [ - ...new Set( - [...body.matchAll(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/giu)].map(match => - Number(match[1]), - ), - ), - ]; -} diff --git a/.github/scripts/contributor-trust/rules.test.mjs b/.github/scripts/contributor-trust/rules.test.mjs deleted file mode 100644 index cc48cf0..0000000 --- a/.github/scripts/contributor-trust/rules.test.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { evaluatePullRequest, linkedIssueNumbers } from './rules.mjs'; - -const completeBody = `## 变更说明 - -修复奖励任务页面的状态同步问题,并补充回归覆盖以避免同类问题再次出现。 - -## 关联任务 - -Closes #89 - -## 验证方式 - -运行 pnpm test,全部测试通过;随后执行 pnpm build,构建成功。 - -## 贡献声明 - -- [x] 我已阅读并理解本次改动,能够回答维护者的问题 -- [x] 我已实际运行上方验证步骤,并如实记录结果 - -AI 使用情况:使用了代码补全,已逐行复核并运行测试。`; - -test('passes a complete external contribution', () => { - const result = evaluatePullRequest({ body: completeBody }); - assert.equal(result.passed, true); - assert.deepEqual(result.missing, []); -}); - -test('lists every missing proof for an empty template', () => { - const result = evaluatePullRequest({ body: 'Closes #XXXXX' }); - assert.equal(result.passed, false); - assert.equal(result.missing.length, 6); -}); - -test('trusted maintainers bypass contributor proof fields', () => { - const result = evaluatePullRequest({ body: '', authorAssociation: 'MEMBER' }); - assert.equal(result.passed, true); - assert.equal(result.trusted, true); -}); - -test('draft pull requests are skipped until ready', () => { - const result = evaluatePullRequest({ body: '', draft: true }); - assert.equal(result.passed, true); - assert.equal(result.skipped, true); -}); - -test('flags bots and unusually large changes for manual review', () => { - const result = evaluatePullRequest({ - body: completeBody, - authorType: 'Bot', - changedFiles: 51, - additions: 1501, - }); - assert.equal(result.passed, true); - assert.equal(result.reviewReasons.length, 3); -}); - -test('requires maintainer authorization for reward work', () => { - const result = evaluatePullRequest({ - body: completeBody, - rewardAuthorization: { - required: true, - authorized: false, - issueNumbers: [89], - }, - }); - assert.equal(result.passed, false); - assert.match(result.missing.join('\n'), /implementation-approved/); -}); - -test('accepts reward work assigned or approved by a maintainer', () => { - const result = evaluatePullRequest({ - body: completeBody, - rewardAuthorization: { - required: true, - authorized: true, - issueNumbers: [89], - }, - }); - assert.equal(result.passed, true); -}); - -test('extracts unique closing issue references', () => { - assert.deepEqual(linkedIssueNumbers('Closes #89, fixes #90 and resolves #89'), [89, 90]); -}); diff --git a/.github/scripts/contributor-trust/run.mjs b/.github/scripts/contributor-trust/run.mjs deleted file mode 100644 index 25e90b4..0000000 --- a/.github/scripts/contributor-trust/run.mjs +++ /dev/null @@ -1,134 +0,0 @@ -import { appendFile } from 'node:fs/promises'; - -import { evaluatePullRequest, linkedIssueNumbers } from './rules.mjs'; - -const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? '').split('/'); -const pullNumber = Number(process.env.TRUST_PR_NUMBER); -const token = process.env.GITHUB_TOKEN; -const marker = ''; - -if (!owner || !repo || !Number.isInteger(pullNumber) || !token) { - throw new Error('GITHUB_REPOSITORY, TRUST_PR_NUMBER and GITHUB_TOKEN are required'); -} - -async function api(path, options = {}, allowNotFound = false) { - const response = await fetch(`https://api.github.com${path}`, { - ...options, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - ...options.headers, - }, - }); - if (allowNotFound && response.status === 404) return null; - if (!response.ok) throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status}`); - return response.status === 204 ? null : response.json(); -} - -async function ensureLabel(name, color, description) { - const encoded = encodeURIComponent(name); - const existing = await api(`/repos/${owner}/${repo}/labels/${encoded}`, {}, true); - if (existing) return; - await api(`/repos/${owner}/${repo}/labels`, { - method: 'POST', - body: JSON.stringify({ name, color, description }), - }); -} - -async function setLabel(name, enabled) { - const encoded = encodeURIComponent(name); - if (enabled) { - await api(`/repos/${owner}/${repo}/issues/${pullNumber}/labels`, { - method: 'POST', - body: JSON.stringify({ labels: [name] }), - }); - } else { - await api( - `/repos/${owner}/${repo}/issues/${pullNumber}/labels/${encoded}`, - { - method: 'DELETE', - }, - true, - ); - } -} - -async function syncComment(body, createWhenMissing) { - const comments = await api(`/repos/${owner}/${repo}/issues/${pullNumber}/comments?per_page=100`); - const existing = comments.find(comment => comment.body?.includes(marker)); - if (existing) { - await api(`/repos/${owner}/${repo}/issues/comments/${existing.id}`, { - method: 'PATCH', - body: JSON.stringify({ body }), - }); - } else if (createWhenMissing) { - await api(`/repos/${owner}/${repo}/issues/${pullNumber}/comments`, { - method: 'POST', - body: JSON.stringify({ body }), - }); - } -} - -const pull = await api(`/repos/${owner}/${repo}/pulls/${pullNumber}`); -const linkedIssues = await Promise.all( - linkedIssueNumbers(pull.body ?? '').map(number => - api(`/repos/${owner}/${repo}/issues/${number}`, {}, true), - ), -); -const rewardIssues = linkedIssues.filter(issue => - issue?.labels?.some(label => label.name === 'reward'), -); -const rewardAuthorization = rewardIssues.length - ? { - required: true, - issueNumbers: rewardIssues.map(issue => issue.number), - authorized: rewardIssues.every( - issue => - issue.assignees?.some(assignee => assignee.login === pull.user?.login) || - issue.labels?.some(label => label.name === 'implementation-approved'), - ), - } - : undefined; -const result = evaluatePullRequest({ - body: pull.body ?? '', - draft: pull.draft, - authorAssociation: pull.author_association, - authorType: pull.user?.type, - changedFiles: pull.changed_files, - additions: pull.additions, - rewardAuthorization, -}); - -await ensureLabel('contributor-check:passed', '1f883d', '贡献信息门禁已通过'); -await ensureLabel('needs-contributor-info', 'd1242f', '需要补充可验证的贡献信息'); -await ensureLabel('needs-maintainer-review', 'bf8700', '需要维护者人工复核'); -await ensureLabel('implementation-approved', '8250df', '维护者已批准贡献者实现该奖励任务'); - -await setLabel('contributor-check:passed', result.passed && !result.skipped); -await setLabel('needs-contributor-info', !result.passed); -await setLabel('needs-maintainer-review', result.reviewReasons.length > 0); - -const missingLines = result.missing.map(item => `- [ ] ${item}`).join('\n'); -const reviewLines = result.reviewReasons.map(item => `- ${item}`).join('\n'); -const comment = `${marker} -## 贡献信息检查 - -${result.passed ? '已通过自动检查。维护者仍会审阅实现质量和实际行为。' : `请补充以下信息后再次更新 PR:\n\n${missingLines}`} -${reviewLines ? `\n### 人工复核提示\n\n${reviewLines}` : ''} - -本检查不禁止使用 AI,但提交者必须理解改动、披露使用情况并提供真实验证证据。`; - -await syncComment(comment, !result.passed || result.reviewReasons.length > 0); - -if (process.env.GITHUB_STEP_SUMMARY) { - await appendFile( - process.env.GITHUB_STEP_SUMMARY, - `## Contributor trust gate\n\n- PR: #${pullNumber}\n- Result: ${result.skipped ? 'skipped (draft)' : result.passed ? 'passed' : 'failed'}\n- Missing: ${result.missing.length}\n- Manual review reasons: ${result.reviewReasons.length}\n`, - ); -} - -if (!result.passed) { - process.exitCode = 1; - console.error(`Contributor information is incomplete: ${result.missing.join('; ')}`); -} diff --git a/.github/workflows/contributor-trust.yml b/.github/workflows/contributor-trust.yml index 5d72a5a..6507973 100644 --- a/.github/workflows/contributor-trust.yml +++ b/.github/workflows/contributor-trust.yml @@ -1,54 +1,38 @@ -name: Contributor Trust Gate +name: Contributor Trust Report on: pull_request_target: - types: [opened, edited, reopened, synchronize, ready_for_review] - workflow_dispatch: - inputs: - pr_number: - description: Pull request number to recheck - required: true - type: number + types: [opened, reopened, synchronize, ready_for_review] + issues: + types: [opened] + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + pull-requests: write + models: read concurrency: - group: contributor-trust-${{ github.event.pull_request.number || inputs.pr_number }} + group: contributor-trust-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}-${{ github.actor }} cancel-in-progress: true jobs: - contributor-trust: - name: Verify contributor evidence + report: + name: Inspect public contributor signals + if: github.actor != 'github-actions[bot]' runs-on: ubuntu-latest - permissions: - contents: read - issues: write - pull-requests: read steps: - - name: Resolve pull request number - id: pr - env: - EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} - INPUT_PR_NUMBER: ${{ inputs.pr_number }} - run: | - pr_number="${EVENT_PR_NUMBER:-$INPUT_PR_NUMBER}" - test -n "$pr_number" - echo "number=$pr_number" >> "$GITHUB_OUTPUT" - - - name: Check out trusted base branch - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.2 + - name: Build contributor trust report + id: trust + uses: Neroxsh/contributor-trust-action@v1 with: - ref: ${{ github.event.pull_request.base.sha || github.event.repository.default_branch }} - persist-credentials: false - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.3.0 - with: - node-version: 24 - - - name: Test trust rules - run: node --test .github/scripts/contributor-trust/rules.test.mjs + github-token: ${{ github.token }} - - name: Evaluate pull request + - name: Record result env: - GITHUB_TOKEN: ${{ github.token }} - TRUST_PR_NUMBER: ${{ steps.pr.outputs.number }} - run: node .github/scripts/contributor-trust/run.mjs + AUTHOR: ${{ steps.trust.outputs.author }} + RISK_LEVEL: ${{ steps.trust.outputs.risk-level }} + RISK_SCORE: ${{ steps.trust.outputs.risk-score }} + run: echo "@${AUTHOR} => ${RISK_LEVEL} (${RISK_SCORE}/100)" From b6e273230b1460218744050781629fa0a8f3e70c Mon Sep 17 00:00:00 2001 From: Nerokzy <1480432576@qq.com> Date: Wed, 15 Jul 2026 22:33:24 +0800 Subject: [PATCH 4/4] fix: read hyphenated action outputs safely --- .github/workflows/contributor-trust.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/contributor-trust.yml b/.github/workflows/contributor-trust.yml index 6507973..005ecc1 100644 --- a/.github/workflows/contributor-trust.yml +++ b/.github/workflows/contributor-trust.yml @@ -33,6 +33,6 @@ jobs: - name: Record result env: AUTHOR: ${{ steps.trust.outputs.author }} - RISK_LEVEL: ${{ steps.trust.outputs.risk-level }} - RISK_SCORE: ${{ steps.trust.outputs.risk-score }} + RISK_LEVEL: ${{ steps.trust.outputs['risk-level'] }} + RISK_SCORE: ${{ steps.trust.outputs['risk-score'] }} run: echo "@${AUTHOR} => ${RISK_LEVEL} (${RISK_SCORE}/100)"