diff --git a/.github/scripts/interaction-guard.mjs b/.github/scripts/interaction-guard.mjs index c2ff8fe785..6ef4afa1b4 100644 --- a/.github/scripts/interaction-guard.mjs +++ b/.github/scripts/interaction-guard.mjs @@ -4,6 +4,19 @@ const limits = { issue: { count: 10, label: "issues" }, pull_request: { count: 4, label: "pull requests" }, }; +// Must match the fromJSON list in the guard job's `if:` in +// .github/workflows/interaction-guard.yml. +const trustedAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +export function isTrustedAuthor(authorAssociation) { + return trustedAssociations.has(authorAssociation); +} + +export function closePayload(kind) { + // state_reason is only valid for issues; PATCHing it on a pull request + // fails with 403 "Insufficient permissions to update the state_reason". + return kind === "pull_request" ? { state: "closed" } : { state: "closed", state_reason: "not_planned" }; +} export function evaluateInteraction({ kind, userCreatedAt, now, recentCount }) { const created = Date.parse(userCreatedAt); @@ -41,6 +54,7 @@ function eventTarget(payload) { kind: "pull_request", number: payload.pull_request.number, author: payload.pull_request.user.login, + authorAssociation: payload.pull_request.author_association, }; } if (payload.issue && !payload.issue.pull_request) { @@ -48,6 +62,7 @@ function eventTarget(payload) { kind: "issue", number: payload.issue.number, author: payload.issue.user.login, + authorAssociation: payload.issue.author_association, }; } return null; @@ -80,6 +95,7 @@ async function main() { const payload = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8")); const target = eventTarget(payload); if (!target) return; + if (isTrustedAuthor(target.authorAssociation)) return; const repo = process.env.GITHUB_REPOSITORY; const now = new Date().toISOString(); @@ -103,7 +119,7 @@ async function main() { }); await github(`/repos/${repo}/issues/${target.number}`, { method: "PATCH", - body: JSON.stringify({ state: "closed", state_reason: "not_planned" }), + body: JSON.stringify(closePayload(target.kind)), }); } diff --git a/.github/scripts/interaction-guard.test.mjs b/.github/scripts/interaction-guard.test.mjs index 38023304b7..c868d13aa1 100644 --- a/.github/scripts/interaction-guard.test.mjs +++ b/.github/scripts/interaction-guard.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { evaluateInteraction } from "./interaction-guard.mjs"; +import { closePayload, evaluateInteraction, isTrustedAuthor } from "./interaction-guard.mjs"; const now = "2026-07-08T00:00:00.000Z"; @@ -55,3 +55,20 @@ test("blocks the 5th pull request in 7 days", () => { assert.equal(result.allowed, false); assert.match(result.reason, /4 pull requests per 7 days/); }); + +test("trusts maintainer associations", () => { + for (const authorAssociation of ["OWNER", "MEMBER", "COLLABORATOR"]) { + assert.equal(isTrustedAuthor(authorAssociation), true); + } +}); + +test("does not trust other associations", () => { + assert.equal(isTrustedAuthor("CONTRIBUTOR"), false); + assert.equal(isTrustedAuthor("NONE"), false); + assert.equal(isTrustedAuthor(undefined), false); +}); + +test("close payload omits state_reason for pull requests", () => { + assert.deepEqual(closePayload("pull_request"), { state: "closed" }); + assert.deepEqual(closePayload("issue"), { state: "closed", state_reason: "not_planned" }); +}); diff --git a/.github/workflows/interaction-guard.yml b/.github/workflows/interaction-guard.yml index e63f20ea79..8b2d49fdd6 100644 --- a/.github/workflows/interaction-guard.yml +++ b/.github/workflows/interaction-guard.yml @@ -13,11 +13,17 @@ permissions: jobs: guard: - if: vars.CI_BUDGET_MODE != 'off' + # Skip maintainer-authored events entirely: no runner spin-up, no + # billed minutes. The guard only exists for untrusted authors. + # The association list must match trustedAssociations in + # .github/scripts/interaction-guard.mjs. + if: >- + vars.CI_BUDGET_MODE != 'off' && + !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), + github.event.pull_request.author_association || github.event.issue.author_association) runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - uses: actions/checkout@v4 - - run: node --test .github/scripts/interaction-guard.test.mjs - run: node .github/scripts/interaction-guard.mjs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 038c35eba8..b2e7397d8e 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -76,3 +76,6 @@ jobs: - name: Frontend type check / build run: pnpm --dir apps/desktop-tauri run build + + - name: Interaction guard script tests + run: node --test .github/scripts/interaction-guard.test.mjs