From cf194419c315a4eb40c4c86c4ca23a17c6018e15 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Tue, 19 May 2026 01:22:59 +0900 Subject: [PATCH] ci(proof): skip real-behavior-proof gate for private maintainers (#83418) * ci(proof): trust maintainer label for private org members Private organization memberships report author_association=CONTRIBUTOR on PRs, so the real-behavior-proof gate currently demands proof from maintainers whose membership is private. The labeler workflow already applies the 'maintainer' label via the team-membership API (which sees private members), so treat that label as an equivalent privileged signal in evaluateRealBehaviorProof. * ci(proof): drop noisy comments * ci(proof): check maintainer team membership via GitHub App token Replace the label-based private-maintainer skip with a direct getMembershipForUserInOrg call using a minted GitHub App token, mirroring the pattern labeler.yml already uses for the same lookup. Removes the race against the labeler workflow and the implicit dependency on the 'maintainer' label having landed first. The App-token steps are continue-on-error so the gate still runs (using the existing author_association path) when the App key secrets are absent or both mints fail. * ci(proof): narrow App token to members:read ClawSweeper review #83418: actions/create-github-app-token defaults to the full installation permission set, but the proof gate only needs the org-members read scope used by teams.getMembershipForUserInOrg. Set permission-members: read on both the primary and fallback mint steps. * docs(changelog): private maintainers skip the real-behavior-proof gate --- .github/workflows/real-behavior-proof.yml | 17 ++++++ CHANGELOG.md | 1 + scripts/github/real-behavior-proof-check.mjs | 23 +++++++- scripts/github/real-behavior-proof-policy.mjs | 29 +++++++++ .../real-behavior-proof-policy.test.ts | 59 ++++++++++++++++++- 5 files changed, 127 insertions(+), 2 deletions(-) diff --git a/.github/workflows/real-behavior-proof.yml b/.github/workflows/real-behavior-proof.yml index 6c41ca5d929..7d14fb2d5a6 100644 --- a/.github/workflows/real-behavior-proof.yml +++ b/.github/workflows/real-behavior-proof.yml @@ -25,5 +25,22 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false + - uses: actions/create-github-app-token@v3 + id: app-token + continue-on-error: true + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + permission-members: read + - uses: actions/create-github-app-token@v3 + id: app-token-fallback + if: steps.app-token.outcome == 'failure' + continue-on-error: true + with: + app-id: "2971289" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} + permission-members: read - name: Check real behavior proof + env: + GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }} run: node scripts/github/real-behavior-proof-check.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6660d9aad73..9a188c518b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -234,6 +234,7 @@ Docs: https://docs.openclaw.ai - WhatsApp: treat `upload-file` as a supported media send intent by lowering path/URL uploads through the channel's normal send-media transport. (#81883) Thanks @ngutman. - iOS: end Live Activities when OpenClaw is connected, idle, or disconnected, and show compact attention states for approval-required reconnects. (#83597) Thanks @ngutman. - Control UI: hide child nav items when collapsing the active sidebar group. Fixes #42167. (#42223) Thanks @Aroool. +- CI/proof: skip the real-behavior-proof gate for private org maintainers by minting a least-privilege (`members: read`) GitHub App token and checking active membership in the `maintainer` team, instead of treating `author_association=CONTRIBUTOR` as definitively external. (#83418) Thanks @romneyda. ## 2026.5.17 diff --git a/scripts/github/real-behavior-proof-check.mjs b/scripts/github/real-behavior-proof-check.mjs index 7c705c72753..fce26ac30d9 100644 --- a/scripts/github/real-behavior-proof-check.mjs +++ b/scripts/github/real-behavior-proof-check.mjs @@ -1,6 +1,9 @@ #!/usr/bin/env node import { readFileSync } from "node:fs"; -import { evaluateRealBehaviorProof } from "./real-behavior-proof-policy.mjs"; +import { + evaluateRealBehaviorProof, + isMaintainerTeamMember, +} from "./real-behavior-proof-policy.mjs"; function escapeCommandValue(value) { return String(value) @@ -23,6 +26,24 @@ if (!pullRequest) { process.exit(0); } +const token = process.env.GH_APP_TOKEN; +const org = event.repository?.owner?.login; +const authorLogin = pullRequest.user?.login; +if (token && org && authorLogin) { + try { + if (await isMaintainerTeamMember({ token, org, login: authorLogin })) { + console.log( + `PR author @${authorLogin} is an active member of the ${org}/maintainer team; skipping real behavior proof gate.`, + ); + process.exit(0); + } + } catch (error) { + console.warn( + `::warning title=Maintainer membership check failed::${escapeCommandValue(error?.message ?? String(error))}`, + ); + } +} + const evaluation = evaluateRealBehaviorProof({ pullRequest }); if (evaluation.passed) { console.log(evaluation.reason); diff --git a/scripts/github/real-behavior-proof-policy.mjs b/scripts/github/real-behavior-proof-policy.mjs index 81620dc099e..24980ea4e4d 100644 --- a/scripts/github/real-behavior-proof-policy.mjs +++ b/scripts/github/real-behavior-proof-policy.mjs @@ -3,6 +3,7 @@ export const PROOF_SUPPLIED_LABEL = "proof: supplied"; export const PROOF_SUFFICIENT_LABEL = "proof: sufficient"; export const NEEDS_REAL_BEHAVIOR_PROOF_LABEL = "triage: needs-real-behavior-proof"; export const MOCK_ONLY_PROOF_LABEL = "triage: mock-only-proof"; +export const MAINTAINER_TEAM_SLUG = "maintainer"; const privilegedAuthorAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); @@ -111,6 +112,34 @@ export function hasProofOverride(labels) { return labelNames(labels).has(PROOF_OVERRIDE_LABEL); } +export async function isMaintainerTeamMember({ + token, + org, + login, + teamSlug = MAINTAINER_TEAM_SLUG, + fetch = globalThis.fetch, +} = {}) { + if (!token || !org || !login) { + return false; + } + const url = `https://api.github.com/orgs/${encodeURIComponent(org)}/teams/${encodeURIComponent(teamSlug)}/memberships/${encodeURIComponent(login)}`; + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (response.status === 404) { + return false; + } + if (!response.ok) { + throw new Error(`Team membership lookup failed: ${response.status}`); + } + const body = await response.json(); + return body?.state === "active"; +} + export function extractRealBehaviorProofSection(body = "") { // Normalize CRLF → LF so regexes and section slicing see GitHub web-editor PR // bodies the same way as locally-authored Markdown. diff --git a/test/scripts/real-behavior-proof-policy.test.ts b/test/scripts/real-behavior-proof-policy.test.ts index dd56d28b356..c81a8f55cf5 100644 --- a/test/scripts/real-behavior-proof-policy.test.ts +++ b/test/scripts/real-behavior-proof-policy.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { MOCK_ONLY_PROOF_LABEL, NEEDS_REAL_BEHAVIOR_PROOF_LABEL, PROOF_OVERRIDE_LABEL, PROOF_SUPPLIED_LABEL, evaluateRealBehaviorProof, + isMaintainerTeamMember, labelsForRealBehaviorProof, } from "../../scripts/github/real-behavior-proof-policy.mjs"; @@ -174,3 +175,59 @@ describe("real-behavior-proof-policy", () => { ).toBe("override"); }); }); + +describe("isMaintainerTeamMember", () => { + function jsonResponse(status: number, body: unknown = {}) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }; + } + + it("returns true for active members", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(200, { state: "active" })); + const result = await isMaintainerTeamMember({ + token: "tok", + org: "openclaw", + login: "private-maint", + fetch, + }); + + expect(result).toBe(true); + expect(fetch).toHaveBeenCalledWith( + "https://api.github.com/orgs/openclaw/teams/maintainer/memberships/private-maint", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer tok", + Accept: "application/vnd.github+json", + }), + }), + ); + }); + + it("returns false for non-active membership states", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(200, { state: "pending" })); + expect(await isMaintainerTeamMember({ token: "t", org: "o", login: "u", fetch })).toBe(false); + }); + + it("returns false when GitHub returns 404", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(404)); + expect(await isMaintainerTeamMember({ token: "t", org: "o", login: "u", fetch })).toBe(false); + }); + + it("returns false when the token, org, or login is missing", async () => { + const fetch = vi.fn(); + expect(await isMaintainerTeamMember({ org: "o", login: "u", fetch })).toBe(false); + expect(await isMaintainerTeamMember({ token: "t", login: "u", fetch })).toBe(false); + expect(await isMaintainerTeamMember({ token: "t", org: "o", fetch })).toBe(false); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("throws on unexpected HTTP errors so the caller can warn and fall back", async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse(500)); + await expect( + isMaintainerTeamMember({ token: "t", org: "o", login: "u", fetch }), + ).rejects.toThrow(/500/); + }); +});