From edd71b9519f87e2f303425078d14c23fe2de5efb Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:29:46 +0800 Subject: [PATCH 1/6] chore: add contribution gate workflows --- .github/APPROVED_CONTRIBUTORS | 10 ++ .github/workflows/approve-contributor.yml | 175 ++++++++++++++++++++++ .github/workflows/issue-gate.yml | 84 +++++++++++ .github/workflows/pr-gate.yml | 84 +++++++++++ CONTRIBUTING.md | 24 +++ 5 files changed, 377 insertions(+) create mode 100644 .github/APPROVED_CONTRIBUTORS create mode 100644 .github/workflows/approve-contributor.yml create mode 100644 .github/workflows/issue-gate.yml create mode 100644 .github/workflows/pr-gate.yml diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS new file mode 100644 index 000000000..10eae33fe --- /dev/null +++ b/.github/APPROVED_CONTRIBUTORS @@ -0,0 +1,10 @@ +# Scoped contribution-gate allowlist. +# +# Maintainers and collaborators bypass the gate automatically. Use this file +# for external contributors who are allowed through the automated front door. +# +# Supported entries: +# pr:username +# issue:username +# all:username +all:Hmbown diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml new file mode 100644 index 000000000..2818786f4 --- /dev/null +++ b/.github/workflows/approve-contributor.yml @@ -0,0 +1,175 @@ +name: Approve gated contributor + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + approve: + runs-on: ubuntu-latest + steps: + - name: Open allowlist update PR + uses: actions/github-script@v7 + with: + script: | + const comment = context.payload.comment; + const issue = context.payload.issue; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const command = (comment.body || '').trim().toLowerCase(); + const scopeByCommand = new Map([ + ['/lgtm', 'pr'], + ['lgtm', 'pr'], + ['/lgtmi', 'issue'], + ['lgtmi', 'issue'], + ]); + const scope = scopeByCommand.get(command); + + if (!scope) return; + if (!privileged.has(comment.author_association)) return; + if (scope === 'pr' && !issue.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.', + }); + return; + } + + const path = '.github/APPROVED_CONTRIBUTORS'; + const targetLogin = issue.user.login; + const normalizedLogin = targetLogin.toLowerCase(); + const entry = `${scope}:${normalizedLogin}`; + + const defaultContent = [ + '# Scoped contribution-gate allowlist.', + '#', + '# Maintainers and collaborators bypass the gate automatically. Use this file', + '# for external contributors who are allowed through the automated front door.', + '#', + '# Supported entries:', + '# pr:username', + '# issue:username', + '# all:username', + '', + ].join('\n'); + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + const { data: repoData } = await github.rest.repos.get({ owner, repo }); + const defaultBranch = repoData.default_branch; + const { data: baseRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${defaultBranch}`, + }); + const baseSha = baseRef.object.sha; + const { data: baseCommit } = await github.rest.git.getCommit({ + owner, + repo, + commit_sha: baseSha, + }); + + let content = defaultContent; + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path, + ref: defaultBranch, + }); + if (!Array.isArray(data) && data.type === 'file') { + content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8'); + } + } catch (error) { + if (error.status !== 404) throw error; + } + + const existing = parseAllowlist(content); + if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`, + }); + return; + } + + const nextContent = `${content.trimEnd()}\n${entry}\n`; + const { data: blob } = await github.rest.git.createBlob({ + owner, + repo, + content: nextContent, + encoding: 'utf-8', + }); + const { data: tree } = await github.rest.git.createTree({ + owner, + repo, + base_tree: baseCommit.tree.sha, + tree: [ + { + path, + mode: '100644', + type: 'blob', + sha: blob.sha, + }, + ], + }); + + const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; + const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`; + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: baseSha, + }); + + const { data: commit } = await github.rest.git.createCommit({ + owner, + repo, + message: `chore: approve @${targetLogin} for ${scope} contributions`, + tree: tree.sha, + parents: [baseSha], + }); + await github.rest.git.updateRef({ + owner, + repo, + ref: `heads/${branchName}`, + sha: commit.sha, + }); + + const { data: pr } = await github.rest.pulls.create({ + owner, + repo, + title: `chore: approve @${targetLogin} for ${scope} contributions`, + head: branchName, + base: defaultBranch, + body: [ + `Adds \`${entry}\` to \`${path}\`.`, + '', + `Requested by @${comment.user.login} in #${issue.number}.`, + ].join('\n'), + }); + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `Created allowlist update PR: ${pr.html_url}`, + }); diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml new file mode 100644 index 000000000..70fe83eb1 --- /dev/null +++ b/.github/workflows/issue-gate.yml @@ -0,0 +1,84 @@ +name: Contribution gate - issues + +on: + issues: + types: [opened, reopened] + +permissions: + contents: read + issues: write + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Close unapproved external issues + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + if (issue.pull_request) return; + if (privileged.has(issue.author_association)) return; + if (issue.user.login === 'github-actions[bot]') return; + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + async function readAllowlist() { + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/APPROVED_CONTRIBUTORS', + ref: context.payload.repository.default_branch, + }); + if (Array.isArray(data) || data.type !== 'file') return new Set(); + return parseAllowlist( + Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + ); + } catch (error) { + if (error.status === 404) return new Set(); + throw error; + } + } + + const allowlist = await readAllowlist(); + const login = issue.user.login.toLowerCase(); + if ( + allowlist.has(login) || + allowlist.has(`all:${login}`) || + allowlist.has(`issue:${login}`) + ) { + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: [ + `Thanks @${issue.user.login} for the report.`, + '', + 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + '', + 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', + ].join('\n'), + }); + + await github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 000000000..4be1758aa --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,84 @@ +name: Contribution gate - pull requests + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - name: Close unapproved external pull requests + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + + if (privileged.has(pr.author_association)) return; + if (pr.user.login === 'github-actions[bot]') return; + if ((pr.head.ref || '').startsWith('contribution-gate/')) return; + + function parseAllowlist(content) { + return new Set( + content + .split(/\r?\n/) + .map(line => line.replace(/#.*/, '').trim().toLowerCase()) + .filter(Boolean) + ); + } + + async function readAllowlist() { + try { + const { data } = await github.rest.repos.getContent({ + owner, + repo, + path: '.github/APPROVED_CONTRIBUTORS', + ref: pr.base.ref, + }); + if (Array.isArray(data) || data.type !== 'file') return new Set(); + return parseAllowlist( + Buffer.from(data.content, data.encoding || 'base64').toString('utf8') + ); + } catch (error) { + if (error.status === 404) return new Set(); + throw error; + } + } + + const allowlist = await readAllowlist(); + const login = pr.user.login.toLowerCase(); + if ( + allowlist.has(login) || + allowlist.has(`all:${login}`) || + allowlist.has(`pr:${login}`) + ) { + return; + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: [ + `Thanks @${pr.user.login} for taking the time to contribute.`, + '', + 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + '', + 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', + ].join('\n'), + }); + + await github.rest.pulls.update({ + owner, + repo, + pull_number: pr.number, + state: 'closed', + }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5ccbf68c6..75dec7311 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,6 +167,30 @@ Issues: Validation: ``` +## Contribution Gate + +CodeWhale uses a maintainer-managed contribution gate for the community front +door. Maintainers and collaborators bypass this gate automatically. External +contributors must be listed in `.github/APPROVED_CONTRIBUTORS` before their +issues or pull requests remain open. + +The allowlist is scoped: + +- `pr:username` allows pull requests. +- `issue:username` allows issues. +- `all:username` allows both. + +When an unapproved external contributor opens an issue or pull request, the +matching gate workflow leaves a short thank-you / CONTRIBUTING pointer and +closes it. A maintainer can approve someone by commenting `/lgtm` on a pull +request for PR access, or `/lgtmi` on an issue for issue access. The exact bare +commands `lgtm` and `lgtmi` are also accepted for compatibility, but the +prefixed forms are preferred because they are harder to trigger accidentally in +ordinary review discussion. + +Approvals do not edit `main` directly. The approval workflow opens a small +allowlist update PR so the new entry is reviewable before it takes effect. + ## Agent-Assisted Improvements CodeWhale is allowed to help improve CodeWhale, but the contribution still has From 0ebfcb7bacb7c90d58bd4bd38ea7d2c8621bf4c9 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:39:48 +0800 Subject: [PATCH 2/6] fix: harden contribution gate bypasses --- .github/workflows/approve-contributor.yml | 9 +++++++++ .github/workflows/issue-gate.yml | 1 - .github/workflows/pr-gate.yml | 2 -- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml index 2818786f4..6c7737510 100644 --- a/.github/workflows/approve-contributor.yml +++ b/.github/workflows/approve-contributor.yml @@ -42,6 +42,15 @@ jobs: }); return; } + if (scope === 'issue' && issue.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.', + }); + return; + } const path = '.github/APPROVED_CONTRIBUTORS'; const targetLogin = issue.user.login; diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 70fe83eb1..6966fdf69 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -55,7 +55,6 @@ jobs: const allowlist = await readAllowlist(); const login = issue.user.login.toLowerCase(); if ( - allowlist.has(login) || allowlist.has(`all:${login}`) || allowlist.has(`issue:${login}`) ) { diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 4be1758aa..428af059c 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -24,7 +24,6 @@ jobs: if (privileged.has(pr.author_association)) return; if (pr.user.login === 'github-actions[bot]') return; - if ((pr.head.ref || '').startsWith('contribution-gate/')) return; function parseAllowlist(content) { return new Set( @@ -56,7 +55,6 @@ jobs: const allowlist = await readAllowlist(); const login = pr.user.login.toLowerCase(); if ( - allowlist.has(login) || allowlist.has(`all:${login}`) || allowlist.has(`pr:${login}`) ) { From c70c350c5c02ece2e15934078b16410fdbd38070 Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:48:01 +0800 Subject: [PATCH 3/6] fix: read contribution allowlist from default branch --- .github/workflows/pr-gate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 428af059c..23fe1f957 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -40,7 +40,7 @@ jobs: owner, repo, path: '.github/APPROVED_CONTRIBUTORS', - ref: pr.base.ref, + ref: context.payload.repository.default_branch, }); if (Array.isArray(data) || data.type !== 'file') return new Set(); return parseAllowlist( From 12e06e849d972186e9a923d1718e6b230fb95f9c Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:58:48 +0800 Subject: [PATCH 4/6] fix: remove dead issue gate guard --- .github/workflows/issue-gate.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index 6966fdf69..e19921ecf 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -21,7 +21,6 @@ jobs: const repo = context.repo.repo; const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); - if (issue.pull_request) return; if (privileged.has(issue.author_association)) return; if (issue.user.login === 'github-actions[bot]') return; From 433a8eaea3e9031291cbb2f38d51f3700a6ed14e Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:17:33 +0800 Subject: [PATCH 5/6] fix: add contribution gate dry run mode --- .github/APPROVED_CONTRIBUTORS | 3 ++- .github/workflows/approve-contributor.yml | 30 ++++++++++++++++++++- .github/workflows/issue-gate.yml | 21 +++++++++++++-- .github/workflows/pr-gate.yml | 21 +++++++++++++-- CONTRIBUTING.md | 33 ++++++++++++++++------- 5 files changed, 92 insertions(+), 16 deletions(-) diff --git a/.github/APPROVED_CONTRIBUTORS b/.github/APPROVED_CONTRIBUTORS index 10eae33fe..23e1a5d45 100644 --- a/.github/APPROVED_CONTRIBUTORS +++ b/.github/APPROVED_CONTRIBUTORS @@ -2,9 +2,10 @@ # # Maintainers and collaborators bypass the gate automatically. Use this file # for external contributors who are allowed through the automated front door. +# Seed active contributors here before switching the gate workflows to enforce mode. # # Supported entries: # pr:username # issue:username # all:username -all:Hmbown +all:hmbown diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml index 6c7737510..5ded39657 100644 --- a/.github/workflows/approve-contributor.yml +++ b/.github/workflows/approve-contributor.yml @@ -9,6 +9,10 @@ permissions: issues: write pull-requests: write +concurrency: + group: contribution-gate-approval + cancel-in-progress: false + jobs: approve: runs-on: ubuntu-latest @@ -56,12 +60,14 @@ jobs: const targetLogin = issue.user.login; const normalizedLogin = targetLogin.toLowerCase(); const entry = `${scope}:${normalizedLogin}`; + const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; const defaultContent = [ '# Scoped contribution-gate allowlist.', '#', '# Maintainers and collaborators bypass the gate automatically. Use this file', '# for external contributors who are allowed through the automated front door.', + '# Seed active contributors here before switching the gate workflows to enforce mode.', '#', '# Supported entries:', '# pr:username', @@ -119,6 +125,29 @@ jobs: return; } + const { data: openPrs } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + }); + const repoFullName = `${owner}/${repo}`.toLowerCase(); + const pendingPr = openPrs.find(openPr => { + const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName; + const body = openPr.body || ''; + return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`); + }); + + if (pendingPr) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`, + }); + return; + } + const nextContent = `${content.trimEnd()}\n${entry}\n`; const { data: blob } = await github.rest.git.createBlob({ owner, @@ -140,7 +169,6 @@ jobs: ], }); - const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor'; const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`; await github.rest.git.createRef({ owner, diff --git a/.github/workflows/issue-gate.yml b/.github/workflows/issue-gate.yml index e19921ecf..70bab864b 100644 --- a/.github/workflows/issue-gate.yml +++ b/.github/workflows/issue-gate.yml @@ -8,11 +8,16 @@ permissions: contents: read issues: write +env: + # Keep new gates observable first. Switch to "enforce" only after maintainers + # have seeded active contributors and reviewed the dry-run signal. + CONTRIBUTION_GATE_MODE: dry-run + jobs: gate: runs-on: ubuntu-latest steps: - - name: Close unapproved external issues + - name: Gate unapproved external issues uses: actions/github-script@v7 with: script: | @@ -20,6 +25,12 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); + const enforceGate = gateMode === 'enforce'; + + if (!['dry-run', 'enforce'].includes(gateMode)) { + core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); + } if (privileged.has(issue.author_association)) return; if (issue.user.login === 'github-actions[bot]') return; @@ -60,6 +71,10 @@ jobs: return; } + const gateMessage = enforceGate + ? 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' + : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this issue is staying open. When enforcement is enabled, issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + await github.rest.issues.createComment({ owner, repo, @@ -67,12 +82,14 @@ jobs: body: [ `Thanks @${issue.user.login} for the report.`, '', - 'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + gateMessage, '', 'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.', ].join('\n'), }); + if (!enforceGate) return; + await github.rest.issues.update({ owner, repo, diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 23fe1f957..3e4052dbd 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -9,11 +9,16 @@ permissions: issues: write pull-requests: write +env: + # Keep new gates observable first. Switch to "enforce" only after maintainers + # have seeded active contributors and reviewed the dry-run signal. + CONTRIBUTION_GATE_MODE: dry-run + jobs: gate: runs-on: ubuntu-latest steps: - - name: Close unapproved external pull requests + - name: Gate unapproved external pull requests uses: actions/github-script@v7 with: script: | @@ -21,6 +26,12 @@ jobs: const owner = context.repo.owner; const repo = context.repo.repo; const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase(); + const enforceGate = gateMode === 'enforce'; + + if (!['dry-run', 'enforce'].includes(gateMode)) { + core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`); + } if (privileged.has(pr.author_association)) return; if (pr.user.login === 'github-actions[bot]') return; @@ -61,6 +72,10 @@ jobs: return; } + const gateMessage = enforceGate + ? 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.' + : 'This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` will be closed automatically.'; + await github.rest.issues.createComment({ owner, repo, @@ -68,12 +83,14 @@ jobs: body: [ `Thanks @${pr.user.login} for taking the time to contribute.`, '', - 'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.', + gateMessage, '', 'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.', ].join('\n'), }); + if (!enforceGate) return; + await github.rest.pulls.update({ owner, repo, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75dec7311..7ed555b9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,9 +170,18 @@ Validation: ## Contribution Gate CodeWhale uses a maintainer-managed contribution gate for the community front -door. Maintainers and collaborators bypass this gate automatically. External -contributors must be listed in `.github/APPROVED_CONTRIBUTORS` before their -issues or pull requests remain open. +door. Maintainers and collaborators bypass this gate automatically. The gate +workflows default to dry-run / comment-only mode so maintainers can observe the +signal before closing contributor work. In dry-run mode, unapproved external +issues and pull requests receive a short thank-you / CONTRIBUTING pointer and +remain open. + +When maintainers are ready to enforce the gate, set +`CONTRIBUTION_GATE_MODE: enforce` in the PR and issue gate workflows. In enforce +mode, external contributors must be listed in +`.github/APPROVED_CONTRIBUTORS` before their issues or pull requests remain +open. Before enabling enforcement, seed the allowlist broadly enough for active +external contributors who should not be interrupted by the rollout. The allowlist is scoped: @@ -180,17 +189,21 @@ The allowlist is scoped: - `issue:username` allows issues. - `all:username` allows both. -When an unapproved external contributor opens an issue or pull request, the -matching gate workflow leaves a short thank-you / CONTRIBUTING pointer and -closes it. A maintainer can approve someone by commenting `/lgtm` on a pull -request for PR access, or `/lgtmi` on an issue for issue access. The exact bare -commands `lgtm` and `lgtmi` are also accepted for compatibility, but the -prefixed forms are preferred because they are harder to trigger accidentally in -ordinary review discussion. +A maintainer can approve someone by commenting `/lgtm` on a pull request for PR +access, or `/lgtmi` on an issue for issue access. The exact bare commands +`lgtm` and `lgtmi` are also accepted for compatibility, but the prefixed forms +are preferred because they are harder to trigger accidentally in ordinary review +discussion. Approvals do not edit `main` directly. The approval workflow opens a small allowlist update PR so the new entry is reviewable before it takes effect. +If the gate fires on a good contributor incorrectly, use the same approval flow +to restore them: comment `/lgtm` or `/lgtmi`, merge the generated allowlist PR, +then reopen the affected issue or pull request. If GitHub will not allow the +closed item to be reopened, ask the contributor to resubmit after the allowlist +PR is merged. + ## Agent-Assisted Improvements CodeWhale is allowed to help improve CodeWhale, but the contribution still has From 22f602d0b5cf3298b47fef0b6eaeb62ca51774db Mon Sep 17 00:00:00 2001 From: Nightt <87569709+nightt5879@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:26:55 +0800 Subject: [PATCH 6/6] fix: paginate pending allowlist PR lookup --- .github/workflows/approve-contributor.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/approve-contributor.yml b/.github/workflows/approve-contributor.yml index 5ded39657..bdd54e026 100644 --- a/.github/workflows/approve-contributor.yml +++ b/.github/workflows/approve-contributor.yml @@ -125,12 +125,18 @@ jobs: return; } - const { data: openPrs } = await github.rest.pulls.list({ - owner, - repo, - state: 'open', - per_page: 100, - }); + const openPrs = []; + for (let page = 1; ; page++) { + const { data: pagePrs } = await github.rest.pulls.list({ + owner, + repo, + state: 'open', + per_page: 100, + page, + }); + openPrs.push(...pagePrs); + if (pagePrs.length < 100) break; + } const repoFullName = `${owner}/${repo}`.toLowerCase(); const pendingPr = openPrs.find(openPr => { const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName;