diff --git a/.github/pr-authors.txt b/.github/pr-authors.txt new file mode 100644 index 0000000000..a65e8ab352 --- /dev/null +++ b/.github/pr-authors.txt @@ -0,0 +1,7 @@ +# Logins allowed to open pull requests, one per line, matched case-insensitively. +# Repository owners, members and collaborators, and bots, are always allowed. +lancy69 +chuiwenwei +xxoingr +kevin9327 +imcreazy diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e049e5a01f..9f58159792 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,9 @@ Target branch: 2.x features and fixes go to `studio`. 1.x takes bug fixes, provider/API compatibility, release/updater, security and platform stability only, on `main-v2`. + +Pull requests are limited to maintainers and invited contributors; one from +anyone else is closed automatically. Open an issue instead. --> ## Summary diff --git a/.github/workflows/pr-author-gate.yml b/.github/workflows/pr-author-gate.yml new file mode 100644 index 0000000000..212266aefb --- /dev/null +++ b/.github/workflows/pr-author-gate.yml @@ -0,0 +1,27 @@ +name: PR author gate + +# Pull requests are limited to maintainers and the logins in .github/pr-authors.txt. +# This runs in the base repository's context, checks out the base branch only, and +# never executes pull-request code; the allowlist it reads is the base branch's. +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-author-gate-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - run: node scripts/pr-author-gate.mjs + env: + GH_TOKEN: ${{ github.token }} diff --git a/scripts/pr-author-gate.mjs b/scripts/pr-author-gate.mjs new file mode 100644 index 0000000000..1048e32000 --- /dev/null +++ b/scripts/pr-author-gate.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +// Closes a newly opened pull request whose author is not on the allowlist. +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const MAINTAINERS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +export function parseAllowlist(text) { + return new Set(text.split("\n").map((line) => line.trim()).filter((line) => line && !line.startsWith("#")) + .map((login) => login.toLowerCase())); +} + +export function decide({ association, authorType, login, allowed }) { + if (authorType === "Bot" || MAINTAINERS.has(association)) return "allow"; + return allowed.has(login.toLowerCase()) ? "allow" : "close"; +} + +export const CLOSING_COMMENT = `Thanks for wanting to contribute. Pull requests are currently limited to the maintainers and a small set of invited contributors, so this one has been closed automatically; nothing is wrong with it. + +Please open an issue instead (a bug report with the steps to reproduce is the most useful). A maintainer will look at it, and if you would like to fix it yourself, say so there: if a maintainer agrees, this pull request can be reopened.`; + +function gh(args, input) { + const result = spawnSync("gh", args, { encoding: "utf8", input, maxBuffer: 4 * 1024 * 1024 }); + if (result.status !== 0) throw new Error(`gh ${args[0]} ${args[1]} failed: ${result.stderr}`); + return result.stdout; +} + +export function run({ event, repository, allowlist, exec = gh, log = console.log }) { + const pr = event.pull_request; + const verdict = decide({ + association: pr.author_association, authorType: pr.user.type, login: pr.user.login, allowed: parseAllowlist(allowlist), + }); + log(`#${pr.number} by ${pr.user.login} (${pr.author_association}): ${verdict}`); + if (verdict === "allow") return verdict; + exec(["pr", "comment", String(pr.number), "--repo", repository, "--body-file", "-"], CLOSING_COMMENT); + exec(["pr", "close", String(pr.number), "--repo", repository]); + return verdict; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + const allowlist = readFileSync(new URL("../.github/pr-authors.txt", import.meta.url), "utf8"); + run({ event, repository: process.env.GITHUB_REPOSITORY, allowlist }); +} diff --git a/scripts/pr-author-gate.test.mjs b/scripts/pr-author-gate.test.mjs new file mode 100644 index 0000000000..09b9409514 --- /dev/null +++ b/scripts/pr-author-gate.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { CLOSING_COMMENT, decide, parseAllowlist, run } from "./pr-author-gate.mjs"; + +const allowlist = "# comment\nLancy69\n\n kevin9327 \n"; +const event = (login, association = "NONE", type = "User") => ({ + pull_request: { number: 7, author_association: association, user: { login, type } }, +}); +const harness = () => { + const calls = []; + return { calls, exec: (args, input) => { calls.push({ args, input }); return ""; } }; +}; +const go = (e, h) => run({ event: e, repository: "o/r", allowlist, exec: h.exec, log: () => {} }); + +test("the allowlist ignores comments and blanks and matches case-insensitively", () => { + assert.deepEqual([...parseAllowlist(allowlist)], ["lancy69", "kevin9327"]); + assert.equal(go(event("LANCY69", "CONTRIBUTOR"), harness()), "allow"); + assert.equal(go(event("kevin9327", "FIRST_TIME_CONTRIBUTOR"), harness()), "allow"); +}); + +for (const association of ["OWNER", "MEMBER", "COLLABORATOR"]) { + test(`${association} is never gated`, () => { + const h = harness(); + assert.equal(go(event("maintainer", association), h), "allow"); + assert.deepEqual(h.calls, []); + }); +} + +test("a bot such as dependabot is never gated", () => { + const h = harness(); + assert.equal(go(event("dependabot[bot]", "NONE", "Bot"), h), "allow"); + assert.deepEqual(h.calls, []); +}); + +for (const association of ["CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE", "MANNEQUIN"]) { + test(`${association} who is not on the list is commented and closed, whatever their history`, () => { + const h = harness(); + assert.equal(go(event("someone-else", association), h), "close"); + assert.deepEqual(h.calls.map((c) => c.args.slice(0, 3)), [["pr", "comment", "7"], ["pr", "close", "7"]]); + assert.equal(h.calls[0].input, CLOSING_COMMENT); + }); +} + +test("decide admits only maintainers, bots and listed logins", () => { + const allowed = new Set(["a"]); + assert.equal(decide({ association: "NONE", authorType: "User", login: "A", allowed }), "allow"); + assert.equal(decide({ association: "CONTRIBUTOR", authorType: "User", login: "b", allowed }), "close"); +}); + +test("the shipped allowlist parses and the workflow is safe", () => { + assert.ok(parseAllowlist(readFileSync(new URL("../.github/pr-authors.txt", import.meta.url), "utf8")).size >= 1); + const text = readFileSync(new URL("../.github/workflows/pr-author-gate.yml", import.meta.url), "utf8"); + assert.match(text, /pull_request_target:\n\s+types: \[opened\]/); + assert.doesNotMatch(text, /head\.(sha|ref)|refs\/pull/); + assert.match(text, /permissions:\n\s+contents: read\n\s+pull-requests: write/); + assert.match(text, /node scripts\/pr-author-gate\.mjs/); +});