ci: limit pull requests to maintainers and an allowlist of invited authors

This commit is contained in:
esengine committed 2026-11-02 01:52:42 -08:00
1 parent c43bd2bdc3
commit 60caacd887
5 files changed
+140

No files matched your search

+7
View File
@@ -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
+3
View File
@@ -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
+27
View File
@@ -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 }}
+45
View File
@@ -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 });
}
+58
View File
@@ -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/);
});