mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-19 07:42:04 +00:00
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.
This commit is contained in:
parent
187d4f928a
commit
ddc7fe15a2
4 changed files with 121 additions and 19 deletions
15
.github/workflows/real-behavior-proof.yml
vendored
15
.github/workflows/real-behavior-proof.yml
vendored
|
|
@ -25,5 +25,20 @@ 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 }}
|
||||
- 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 }}
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3,7 +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_AUTHOR_LABEL = "maintainer";
|
||||
export const MAINTAINER_TEAM_SLUG = "maintainer";
|
||||
|
||||
const privilegedAuthorAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ function isAutomationUser(user = {}, fallbackLogin = "") {
|
|||
return user?.type === "Bot" || /\[bot\]$/i.test(login) || login.startsWith("app/");
|
||||
}
|
||||
|
||||
export function isExternalPullRequest(pullRequest, labels) {
|
||||
export function isExternalPullRequest(pullRequest) {
|
||||
if (!pullRequest) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -105,21 +105,39 @@ export function isExternalPullRequest(pullRequest, labels) {
|
|||
const authorAssociation = String(
|
||||
pullRequest.author_association ?? pullRequest.authorAssociation ?? "",
|
||||
).toUpperCase();
|
||||
if (privilegedAuthorAssociations.has(authorAssociation)) {
|
||||
return false;
|
||||
}
|
||||
if (hasMaintainerAuthorLabel(labels ?? pullRequest.labels)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return !privilegedAuthorAssociations.has(authorAssociation);
|
||||
}
|
||||
|
||||
export function hasProofOverride(labels) {
|
||||
return labelNames(labels).has(PROOF_OVERRIDE_LABEL);
|
||||
}
|
||||
|
||||
export function hasMaintainerAuthorLabel(labels) {
|
||||
return labelNames(labels).has(MAINTAINER_AUTHOR_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 = "") {
|
||||
|
|
@ -222,7 +240,7 @@ export function evaluateRealBehaviorProof({ pullRequest, labels } = {}) {
|
|||
if (hasProofOverride(currentLabels)) {
|
||||
return result("override", `Maintainer override label ${PROOF_OVERRIDE_LABEL} is present.`);
|
||||
}
|
||||
if (!isExternalPullRequest(pullRequest, currentLabels)) {
|
||||
if (!isExternalPullRequest(pullRequest)) {
|
||||
return result("skipped", "Maintainer, collaborator, or bot PRs do not require this gate.");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -173,13 +174,60 @@ describe("real-behavior-proof-policy", () => {
|
|||
}).status,
|
||||
).toBe("override");
|
||||
});
|
||||
});
|
||||
|
||||
it("skips private maintainers identified by the maintainer label", () => {
|
||||
const evaluation = evaluateRealBehaviorProof({
|
||||
pullRequest: externalPr("", { labels: [{ name: "maintainer" }] }),
|
||||
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(evaluation.status).toBe("skipped");
|
||||
expect(labelsForRealBehaviorProof(evaluation)).toEqual([]);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue