Skip to content

Commit 1d04509

Browse files
zakiskchmouel
authored andcommitted
test(e2e): allow external PRs via ok-to-test label
Add ok-to-test label gating as a fallback for external contributors who are not org members or repo collaborators. Maintainers can approve external PRs by adding the ok-to-test label after code review. The label is automatically removed on synchronize, opened, and reopened events to prevent running CI on new untrusted code without re-approval. Signed-off-by: Zaki Shaikh <zashaikh@redhat.com> Assisted-by: Claude Opus 4.6 (via Claude Code)
1 parent 3872461 commit 1d04509

File tree

2 files changed

+225
-159
lines changed

2 files changed

+225
-159
lines changed
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Check PR author permissions for pull_request_target events.
2+
// Used by the e2e workflow to gate CI runs on external PRs.
3+
//
4+
// Permission check order:
5+
// 1. Trusted bots (dependabot, renovate)
6+
// 2. Org/team membership
7+
// 3. Repository collaborator (write/admin)
8+
// 4. ok-to-test label (maintainer approval for external contributors)
9+
//
10+
// Security: on non-labeled events the ok-to-test label is removed
11+
// to force re-approval after code changes.
12+
module.exports = async ({ github, context, core }) => {
13+
if (!context || !context.payload || !context.payload.pull_request) {
14+
core.setFailed(
15+
"Invalid GitHub context: missing required pull_request information",
16+
);
17+
return;
18+
}
19+
20+
const actor = context.payload.pull_request.user.login;
21+
const repoOwner = context.repo.owner;
22+
const repoName = context.repo.repo;
23+
const targetOrg = context.repo.owner;
24+
25+
core.info(`🔍 Starting permission check for user: @${actor}`);
26+
core.info(`📋 Repository: ${repoOwner}/${repoName}`);
27+
core.info(`🏢 Target organization: ${targetOrg}`);
28+
29+
// Security: On non-labeled events (opened, reopened, synchronize),
30+
// remove the ok-to-test label if present. This prevents an external
31+
// contributor from pushing malicious code after a maintainer approved via label.
32+
if (context.payload.action !== "labeled") {
33+
const currentLabels = context.payload.pull_request.labels.map(
34+
(l) => l.name,
35+
);
36+
if (currentLabels.includes("ok-to-test")) {
37+
core.info(
38+
`🔒 Removing ok-to-test label due to '${context.payload.action}' event — re-approval required.`,
39+
);
40+
try {
41+
await github.rest.issues.removeLabel({
42+
owner: repoOwner,
43+
repo: repoName,
44+
issue_number: context.payload.pull_request.number,
45+
name: "ok-to-test",
46+
});
47+
core.info(` Label removed successfully.`);
48+
} catch (err) {
49+
// 404 is expected when multiple matrix jobs race to remove the same label
50+
if (err.status === 404) {
51+
core.info(` Label already removed (likely by another matrix job).`);
52+
} else {
53+
core.warning(` Failed to remove ok-to-test label: ${err.message}`);
54+
}
55+
}
56+
}
57+
}
58+
59+
// Condition 1: Check if the user is a trusted bot.
60+
const trustedBots = ["dependabot[bot]", "renovate[bot]"];
61+
core.info(`🤖 Checking if @${actor} is a trusted bot...`);
62+
core.info(` Trusted bots list: ${trustedBots.join(", ")}`);
63+
64+
if (trustedBots.includes(actor)) {
65+
core.info(
66+
`✅ Condition met: User @${actor} is a trusted bot. Proceeding.`,
67+
);
68+
return;
69+
}
70+
core.info(` ❌ User @${actor} is not a trusted bot.`);
71+
72+
// Condition 2: Check for public membership in the target organization.
73+
core.info(`\n👥 Condition 2: Checking organization and team membership...`);
74+
core.info(
75+
`User @${actor} is not a trusted bot. Checking for membership in '${targetOrg}'...`,
76+
);
77+
try {
78+
// Optional: check membership in one or more org teams (set TARGET_TEAM_SLUGS as comma-separated slugs in workflow env)
79+
const teamSlugsEnv = process.env.TARGET_TEAM_SLUGS || "";
80+
const teamSlugs = teamSlugsEnv
81+
.split(",")
82+
.map((s) => s.trim())
83+
.filter(Boolean);
84+
85+
core.info(`🔧 TARGET_TEAM_SLUGS environment variable: "${teamSlugsEnv}"`);
86+
core.info(`📝 Parsed team slugs: [${teamSlugs.join(", ")}]`);
87+
88+
if (teamSlugs.length > 0) {
89+
core.info(
90+
`🔍 Checking team membership for ${teamSlugs.length} team(s)...`,
91+
);
92+
for (const team_slug of teamSlugs) {
93+
core.info(` Checking team: ${team_slug}...`);
94+
try {
95+
const membership =
96+
await github.rest.teams.getMembershipForUserInOrg({
97+
org: targetOrg,
98+
team_slug,
99+
username: actor,
100+
});
101+
core.info(
102+
` API response for team '${team_slug}': ${JSON.stringify(membership.data)}`,
103+
);
104+
if (
105+
membership &&
106+
membership.data &&
107+
membership.data.state === "active"
108+
) {
109+
core.info(
110+
`✅ Condition met: User @${actor} is a member of team '${team_slug}' in '${targetOrg}'. Proceeding.`,
111+
);
112+
return;
113+
} else {
114+
core.info(
115+
` ⚠️ Team membership found but state is not 'active': ${membership.data.state}`,
116+
);
117+
}
118+
} catch (err) {
119+
core.info(
120+
` ❌ User @${actor} is not a member of team '${team_slug}' (or team not found). Error: ${err.message}`,
121+
);
122+
}
123+
}
124+
core.info(
125+
`ⓘ User @${actor} is not a member of any configured teams in '${targetOrg}'. Falling back to org membership checks.`,
126+
);
127+
} else {
128+
core.info(
129+
`ℹ️ No teams configured in TARGET_TEAM_SLUGS. Skipping team membership checks.`,
130+
);
131+
}
132+
core.info(
133+
`🏢 Checking organization membership for @${actor} in '${targetOrg}'...`,
134+
);
135+
try {
136+
core.info(` Attempting checkMembershipForUser API call...`);
137+
await github.rest.orgs.checkMembershipForUser({
138+
org: targetOrg,
139+
username: actor,
140+
});
141+
core.info(
142+
`✅ Condition met: User @${actor} is a member of '${targetOrg}'. Proceeding.`,
143+
);
144+
return;
145+
} catch (err) {
146+
core.info(` ❌ Private membership check failed: ${err.message}`);
147+
core.info(` Attempting checkPublicMembershipForUser API call...`);
148+
try {
149+
await github.rest.orgs.checkPublicMembershipForUser({
150+
org: targetOrg,
151+
username: actor,
152+
});
153+
core.info(
154+
`✅ Condition met: User @${actor} is a public member of '${targetOrg}'. Proceeding.`,
155+
);
156+
return;
157+
} catch (publicErr) {
158+
core.info(
159+
` ❌ Public membership check failed: ${publicErr.message}`,
160+
);
161+
throw publicErr;
162+
}
163+
}
164+
} catch (error) {
165+
core.info(
166+
`ⓘ User @${actor} is not a public member of '${targetOrg}'. Checking repository permissions as a fallback.`,
167+
);
168+
}
169+
170+
// Condition 3: Check for write/admin permission on the repository.
171+
core.info(
172+
`\n🔐 Condition 3: Checking repository collaborator permissions...`,
173+
);
174+
try {
175+
core.info(` Attempting getCollaboratorPermissionLevel API call...`);
176+
const response = await github.rest.repos.getCollaboratorPermissionLevel({
177+
owner: repoOwner,
178+
repo: repoName,
179+
username: actor,
180+
});
181+
182+
const permission = response.data.permission;
183+
core.info(
184+
` User @${actor} has '${permission}' permission on ${repoOwner}/${repoName}`,
185+
);
186+
187+
if (permission === "admin" || permission === "write") {
188+
core.info(
189+
`✅ Condition met: User @${actor} has '${permission}' repository permission. Proceeding.`,
190+
);
191+
return;
192+
} else {
193+
core.info(
194+
` ❌ Permission '${permission}' is insufficient (requires 'write' or 'admin')`,
195+
);
196+
}
197+
} catch (error) {
198+
core.info(` ❌ Collaborator permission check failed: ${error.message}`);
199+
}
200+
201+
// Condition 4: Check for ok-to-test label (for external contributors approved by maintainers).
202+
// Only users with repo write access can add labels, so this is inherently gated.
203+
core.info(`\n🏷️ Condition 4: Checking for ok-to-test label...`);
204+
if (
205+
context.payload.action === "labeled" &&
206+
context.payload.label &&
207+
context.payload.label.name === "ok-to-test"
208+
) {
209+
core.info(
210+
`✅ Condition met: ok-to-test label applied by @${context.actor}. Proceeding with tests.`,
211+
);
212+
return;
213+
}
214+
215+
core.info(` ❌ No ok-to-test label event detected.`);
216+
core.setFailed(
217+
`❌ Permission check failed. User @${actor} did not meet any required conditions (trusted bot, org/team member, repo write access, or ok-to-test label).`,
218+
);
219+
};

0 commit comments

Comments
 (0)