From b9f500a7551449aeef6b6eea1d9822774297b428 Mon Sep 17 00:00:00 2001 From: Finesssee Date: Wed, 29 Jul 2026 16:24:01 +0000 Subject: [PATCH 1/2] Fix interaction guard maintainer blocking and PR close Co-authored-by: Codesmith --- .github/scripts/interaction-guard.mjs | 23 ++++++++++++++++-- .github/scripts/interaction-guard.test.mjs | 28 +++++++++++++++++++++- .github/workflows/interaction-guard.yml | 8 +++++-- .github/workflows/pr-check.yml | 3 +++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/.github/scripts/interaction-guard.mjs b/.github/scripts/interaction-guard.mjs index c2ff8fe785..d6570a2f6b 100644 --- a/.github/scripts/interaction-guard.mjs +++ b/.github/scripts/interaction-guard.mjs @@ -4,8 +4,23 @@ const limits = { issue: { count: 10, label: "issues" }, pull_request: { count: 4, label: "pull requests" }, }; +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, authorAssociation, userCreatedAt, now, recentCount }) { + if (isTrustedAuthor(authorAssociation)) { + return { allowed: true }; + } -export function evaluateInteraction({ kind, userCreatedAt, now, recentCount }) { const created = Date.parse(userCreatedAt); const current = Date.parse(now); if (!Number.isFinite(created) || !Number.isFinite(current)) { @@ -41,6 +56,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 +64,7 @@ function eventTarget(payload) { kind: "issue", number: payload.issue.number, author: payload.issue.user.login, + authorAssociation: payload.issue.author_association, }; } return null; @@ -80,6 +97,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(); @@ -89,6 +107,7 @@ async function main() { const recent = await github(`/search/issues?q=${query}&per_page=1`); const result = evaluateInteraction({ kind: target.kind, + authorAssociation: target.authorAssociation, userCreatedAt: user.created_at, now, recentCount: recent.total_count, @@ -103,7 +122,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..539691281a 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,29 @@ 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("exempts maintainers even when over the limits", () => { + for (const authorAssociation of ["OWNER", "MEMBER", "COLLABORATOR"]) { + const result = evaluateInteraction({ + kind: "pull_request", + author: "maintainer", + authorAssociation, + userCreatedAt: "2026-07-01T00:00:00.000Z", + now, + recentCount: 50, + }); + + assert.equal(result.allowed, true); + } +}); + +test("does not exempt untrusted 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..a5feb6b615 100644 --- a/.github/workflows/interaction-guard.yml +++ b/.github/workflows/interaction-guard.yml @@ -13,11 +13,15 @@ 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. + 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 From 54ab60046bf1a230ee751412ba2a65054610b2ca Mon Sep 17 00:00:00 2001 From: Finesssee Date: Wed, 29 Jul 2026 17:16:52 +0000 Subject: [PATCH 2/2] Decide guard trust once in main, keep evaluator pure Co-authored-by: Codesmith --- .github/scripts/interaction-guard.mjs | 9 +++------ .github/scripts/interaction-guard.test.mjs | 15 +++------------ .github/workflows/interaction-guard.yml | 2 ++ 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/.github/scripts/interaction-guard.mjs b/.github/scripts/interaction-guard.mjs index d6570a2f6b..6ef4afa1b4 100644 --- a/.github/scripts/interaction-guard.mjs +++ b/.github/scripts/interaction-guard.mjs @@ -4,6 +4,8 @@ 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) { @@ -16,11 +18,7 @@ export function closePayload(kind) { return kind === "pull_request" ? { state: "closed" } : { state: "closed", state_reason: "not_planned" }; } -export function evaluateInteraction({ kind, authorAssociation, userCreatedAt, now, recentCount }) { - if (isTrustedAuthor(authorAssociation)) { - return { allowed: true }; - } - +export function evaluateInteraction({ kind, userCreatedAt, now, recentCount }) { const created = Date.parse(userCreatedAt); const current = Date.parse(now); if (!Number.isFinite(created) || !Number.isFinite(current)) { @@ -107,7 +105,6 @@ async function main() { const recent = await github(`/search/issues?q=${query}&per_page=1`); const result = evaluateInteraction({ kind: target.kind, - authorAssociation: target.authorAssociation, userCreatedAt: user.created_at, now, recentCount: recent.total_count, diff --git a/.github/scripts/interaction-guard.test.mjs b/.github/scripts/interaction-guard.test.mjs index 539691281a..c868d13aa1 100644 --- a/.github/scripts/interaction-guard.test.mjs +++ b/.github/scripts/interaction-guard.test.mjs @@ -56,22 +56,13 @@ test("blocks the 5th pull request in 7 days", () => { assert.match(result.reason, /4 pull requests per 7 days/); }); -test("exempts maintainers even when over the limits", () => { +test("trusts maintainer associations", () => { for (const authorAssociation of ["OWNER", "MEMBER", "COLLABORATOR"]) { - const result = evaluateInteraction({ - kind: "pull_request", - author: "maintainer", - authorAssociation, - userCreatedAt: "2026-07-01T00:00:00.000Z", - now, - recentCount: 50, - }); - - assert.equal(result.allowed, true); + assert.equal(isTrustedAuthor(authorAssociation), true); } }); -test("does not exempt untrusted associations", () => { +test("does not trust other associations", () => { assert.equal(isTrustedAuthor("CONTRIBUTOR"), false); assert.equal(isTrustedAuthor("NONE"), false); assert.equal(isTrustedAuthor(undefined), false); diff --git a/.github/workflows/interaction-guard.yml b/.github/workflows/interaction-guard.yml index a5feb6b615..8b2d49fdd6 100644 --- a/.github/workflows/interaction-guard.yml +++ b/.github/workflows/interaction-guard.yml @@ -15,6 +15,8 @@ jobs: guard: # 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"]'),