Skip to content

Commit b4c4dea

Browse files
committed
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 56a3a61 commit b4c4dea

File tree

2 files changed

+219
-158
lines changed

2 files changed

+219
-158
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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+
core.warning(` Failed to remove ok-to-test label: ${err.message}`);
50+
}
51+
}
52+
}
53+
54+
// Condition 1: Check if the user is a trusted bot.
55+
const trustedBots = ["dependabot[bot]", "renovate[bot]"];
56+
core.info(`🤖 Checking if @${actor} is a trusted bot...`);
57+
core.info(` Trusted bots list: ${trustedBots.join(", ")}`);
58+
59+
if (trustedBots.includes(actor)) {
60+
core.info(
61+
`✅ Condition met: User @${actor} is a trusted bot. Proceeding.`,
62+
);
63+
return;
64+
}
65+
core.info(` ❌ User @${actor} is not a trusted bot.`);
66+
67+
// Condition 2: Check for public membership in the target organization.
68+
core.info(`\n👥 Condition 2: Checking organization and team membership...`);
69+
core.info(
70+
`User @${actor} is not a trusted bot. Checking for membership in '${targetOrg}'...`,
71+
);
72+
try {
73+
// Optional: check membership in one or more org teams (set TARGET_TEAM_SLUGS as comma-separated slugs in workflow env)
74+
const teamSlugsEnv = process.env.TARGET_TEAM_SLUGS || "";
75+
const teamSlugs = teamSlugsEnv
76+
.split(",")
77+
.map((s) => s.trim())
78+
.filter(Boolean);
79+
80+
core.info(`🔧 TARGET_TEAM_SLUGS environment variable: "${teamSlugsEnv}"`);
81+
core.info(`📝 Parsed team slugs: [${teamSlugs.join(", ")}]`);
82+
83+
if (teamSlugs.length > 0) {
84+
core.info(
85+
`🔍 Checking team membership for ${teamSlugs.length} team(s)...`,
86+
);
87+
for (const team_slug of teamSlugs) {
88+
core.info(` Checking team: ${team_slug}...`);
89+
try {
90+
const membership =
91+
await github.rest.teams.getMembershipForUserInOrg({
92+
org: targetOrg,
93+
team_slug,
94+
username: actor,
95+
});
96+
core.info(
97+
` API response for team '${team_slug}': ${JSON.stringify(membership.data)}`,
98+
);
99+
if (
100+
membership &&
101+
membership.data &&
102+
membership.data.state === "active"
103+
) {
104+
core.info(
105+
`✅ Condition met: User @${actor} is a member of team '${team_slug}' in '${targetOrg}'. Proceeding.`,
106+
);
107+
return;
108+
} else {
109+
core.info(
110+
` ⚠️ Team membership found but state is not 'active': ${membership.data.state}`,
111+
);
112+
}
113+
} catch (err) {
114+
core.info(
115+
` ❌ User @${actor} is not a member of team '${team_slug}' (or team not found). Error: ${err.message}`,
116+
);
117+
}
118+
}
119+
core.info(
120+
`ⓘ User @${actor} is not a member of any configured teams in '${targetOrg}'. Falling back to org membership checks.`,
121+
);
122+
} else {
123+
core.info(
124+
`ℹ️ No teams configured in TARGET_TEAM_SLUGS. Skipping team membership checks.`,
125+
);
126+
}
127+
core.info(
128+
`🏢 Checking organization membership for @${actor} in '${targetOrg}'...`,
129+
);
130+
try {
131+
core.info(` Attempting checkMembershipForUser API call...`);
132+
await github.rest.orgs.checkMembershipForUser({
133+
org: targetOrg,
134+
username: actor,
135+
});
136+
core.info(
137+
`✅ Condition met: User @${actor} is a member of '${targetOrg}'. Proceeding.`,
138+
);
139+
return;
140+
} catch (err) {
141+
core.info(` ❌ Private membership check failed: ${err.message}`);
142+
core.info(` Attempting checkPublicMembershipForUser API call...`);
143+
try {
144+
await github.rest.orgs.checkPublicMembershipForUser({
145+
org: targetOrg,
146+
username: actor,
147+
});
148+
core.info(
149+
`✅ Condition met: User @${actor} is a public member of '${targetOrg}'. Proceeding.`,
150+
);
151+
return;
152+
} catch (publicErr) {
153+
core.info(
154+
` ❌ Public membership check failed: ${publicErr.message}`,
155+
);
156+
throw publicErr;
157+
}
158+
}
159+
} catch (error) {
160+
core.info(
161+
`ⓘ User @${actor} is not a public member of '${targetOrg}'. Checking repository permissions as a fallback.`,
162+
);
163+
}
164+
165+
// Condition 3: Check for write/admin permission on the repository.
166+
core.info(
167+
`\n🔐 Condition 3: Checking repository collaborator permissions...`,
168+
);
169+
try {
170+
core.info(` Attempting getCollaboratorPermissionLevel API call...`);
171+
const response = await github.rest.repos.getCollaboratorPermissionLevel({
172+
owner: repoOwner,
173+
repo: repoName,
174+
username: actor,
175+
});
176+
177+
const permission = response.data.permission;
178+
core.info(
179+
` User @${actor} has '${permission}' permission on ${repoOwner}/${repoName}`,
180+
);
181+
182+
if (permission === "admin" || permission === "write") {
183+
core.info(
184+
`✅ Condition met: User @${actor} has '${permission}' repository permission. Proceeding.`,
185+
);
186+
return;
187+
} else {
188+
core.info(
189+
` ❌ Permission '${permission}' is insufficient (requires 'write' or 'admin')`,
190+
);
191+
}
192+
} catch (error) {
193+
core.info(` ❌ Collaborator permission check failed: ${error.message}`);
194+
}
195+
196+
// Condition 4: Check for ok-to-test label (for external contributors approved by maintainers).
197+
// Only users with repo write access can add labels, so this is inherently gated.
198+
core.info(`\n🏷️ Condition 4: Checking for ok-to-test label...`);
199+
if (
200+
context.payload.action === "labeled" &&
201+
context.payload.label &&
202+
context.payload.label.name === "ok-to-test"
203+
) {
204+
core.info(
205+
`✅ Condition met: ok-to-test label applied by @${context.actor}. Proceeding with tests.`,
206+
);
207+
return;
208+
}
209+
210+
core.info(` ❌ No ok-to-test label event detected.`);
211+
core.setFailed(
212+
`❌ Permission check failed. User @${actor} did not meet any required conditions (trusted bot, org/team member, repo write access, or ok-to-test label).`,
213+
);
214+
};

.github/workflows/e2e.yaml

Lines changed: 5 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ on:
1919
- opened
2020
- reopened
2121
- synchronize
22+
- labeled
2223
paths:
2324
- "**.go"
2425
- ".github/workflows/**"
@@ -32,7 +33,8 @@ jobs:
3233
if: >
3334
github.event_name == 'schedule' ||
3435
github.event_name == 'workflow_dispatch' ||
35-
github.event_name == 'pull_request_target'
36+
(github.event_name == 'pull_request_target' && github.event.action != 'labeled') ||
37+
(github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'ok-to-test')
3638
concurrency:
3739
group: ${{ github.workflow }}-${{ matrix.provider }}-${{ github.event.pull_request.number || github.ref_name }}
3840
cancel-in-progress: true
@@ -131,163 +133,8 @@ jobs:
131133
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
132134
with:
133135
script: |
134-
if (!context || !context.payload || !context.payload.pull_request) {
135-
core.setFailed('Invalid GitHub context: missing required pull_request information');
136-
return;
137-
}
138-
139-
async function run() {
140-
const actor = context.payload.pull_request.user.login;
141-
const repoOwner = context.repo.owner;
142-
const repoName = context.repo.repo;
143-
const targetOrg = context.repo.owner;
144-
145-
core.info(`🔍 Starting permission check for user: @${actor}`);
146-
core.info(`📋 Repository: ${repoOwner}/${repoName}`);
147-
core.info(`🏢 Target organization: ${targetOrg}`);
148-
149-
// Condition 1: Check if the user is a trusted bot.
150-
const trustedBots = ["dependabot[bot]", "renovate[bot]"];
151-
core.info(`🤖 Checking if @${actor} is a trusted bot...`);
152-
core.info(` Trusted bots list: ${trustedBots.join(', ')}`);
153-
154-
if (trustedBots.includes(actor)) {
155-
core.info(`✅ Condition met: User @${actor} is a trusted bot. Proceeding.`);
156-
return; // Success
157-
}
158-
core.info(` ❌ User @${actor} is not a trusted bot.`);
159-
160-
// Condition 2: Check for public membership in the target organization.
161-
core.info(`\n👥 Condition 2: Checking organization and team membership...`);
162-
core.info(
163-
`User @${actor} is not a trusted bot. Checking for membership in '${targetOrg}'...`,
164-
);
165-
try {
166-
// Optional: check membership in one or more org teams (set TARGET_TEAM_SLUGS as comma-separated slugs in workflow env)
167-
const teamSlugsEnv = process.env.TARGET_TEAM_SLUGS || "";
168-
const teamSlugs = teamSlugsEnv
169-
.split(",")
170-
.map((s) => s.trim())
171-
.filter(Boolean);
172-
173-
core.info(`🔧 TARGET_TEAM_SLUGS environment variable: "${teamSlugsEnv}"`);
174-
core.info(`📝 Parsed team slugs: [${teamSlugs.join(', ')}]`);
175-
176-
if (teamSlugs.length > 0) {
177-
core.info(`🔍 Checking team membership for ${teamSlugs.length} team(s)...`);
178-
for (const team_slug of teamSlugs) {
179-
core.info(` Checking team: ${team_slug}...`);
180-
try {
181-
const membership = await github.rest.teams.getMembershipForUserInOrg({
182-
org: targetOrg,
183-
team_slug,
184-
username: actor,
185-
});
186-
core.info(` API response for team '${team_slug}': ${JSON.stringify(membership.data)}`);
187-
if (
188-
membership &&
189-
membership.data &&
190-
membership.data.state === "active"
191-
) {
192-
core.info(
193-
`✅ Condition met: User @${actor} is a member of team '${team_slug}' in '${targetOrg}'. Proceeding.`,
194-
);
195-
return; // Success
196-
} else {
197-
core.info(` ⚠️ Team membership found but state is not 'active': ${membership.data.state}`);
198-
}
199-
} catch (err) {
200-
// Not a member of this team or team doesn't exist — continue to next
201-
core.info(
202-
` ❌ User @${actor} is not a member of team '${team_slug}' (or team not found). Error: ${err.message}`,
203-
);
204-
}
205-
}
206-
// If we tried team checks and none matched, continue to next org membership checks
207-
core.info(
208-
`ⓘ User @${actor} is not a member of any configured teams in '${targetOrg}'. Falling back to org membership checks.`,
209-
);
210-
} else {
211-
core.info(`ℹ️ No teams configured in TARGET_TEAM_SLUGS. Skipping team membership checks.`);
212-
}
213-
core.info(`🏢 Checking organization membership for @${actor} in '${targetOrg}'...`);
214-
try {
215-
core.info(` Attempting checkMembershipForUser API call...`);
216-
await github.rest.orgs.checkMembershipForUser({
217-
org: targetOrg,
218-
username: actor,
219-
});
220-
core.info(
221-
`✅ Condition met: User @${actor} is a member of '${targetOrg}'. Proceeding.`,
222-
);
223-
return; // Success
224-
} catch (err) {
225-
// Try public membership as fallback
226-
core.info(` ❌ Private membership check failed: ${err.message}`);
227-
core.info(` Attempting checkPublicMembershipForUser API call...`);
228-
try {
229-
await github.rest.orgs.checkPublicMembershipForUser({
230-
org: targetOrg,
231-
username: actor,
232-
});
233-
core.info(
234-
`✅ Condition met: User @${actor} is a public member of '${targetOrg}'. Proceeding.`,
235-
);
236-
return; // Success
237-
} catch (publicErr) {
238-
// Neither private nor public member - will be caught by outer catch
239-
core.info(` ❌ Public membership check failed: ${publicErr.message}`);
240-
throw publicErr;
241-
}
242-
}
243-
} catch (error) {
244-
// This is not a failure, just one unmet condition. Log and continue.
245-
core.info(
246-
`ⓘ User @${actor} is not a public member of '${targetOrg}'. Checking repository permissions as a fallback.`,
247-
);
248-
}
249-
250-
// Condition 3: Check for write/admin permission on the repository.
251-
core.info(`\n🔐 Condition 3: Checking repository collaborator permissions...`);
252-
try {
253-
core.info(` Attempting getCollaboratorPermissionLevel API call...`);
254-
const response = await github.rest.repos.getCollaboratorPermissionLevel({
255-
owner: repoOwner,
256-
repo: repoName,
257-
username: actor,
258-
});
259-
260-
const permission = response.data.permission;
261-
core.info(` User @${actor} has '${permission}' permission on ${repoOwner}/${repoName}`);
262-
263-
if (permission === "admin" || permission === "write") {
264-
core.info(
265-
`✅ Condition met: User @${actor} has '${permission}' repository permission. Proceeding.`,
266-
);
267-
return; // Success
268-
} else {
269-
// If we reach here, no conditions were met. This is the final failure.
270-
core.info(` ❌ Permission '${permission}' is insufficient (requires 'write' or 'admin')`);
271-
core.setFailed(
272-
`❌ Permission check failed. User @${actor} did not meet any required conditions (trusted bot, org member, or repo write access).`,
273-
);
274-
return;
275-
}
276-
} catch (error) {
277-
// This error means they are not even a collaborator.
278-
core.info(` ❌ Collaborator permission check failed: ${error.message}`);
279-
core.setFailed(
280-
`❌ Permission check failed. User @${actor} is not a collaborator on this repository and did not meet other conditions.`,
281-
);
282-
return;
283-
}
284-
}
285-
286-
run().catch(err => {
287-
core.error(`💥 Unexpected error during permission check: ${err.message}`);
288-
core.error(` Stack trace: ${err.stack}`);
289-
core.setFailed(`Unexpected error during permission check: ${err.message}`);
290-
});
136+
const script = require('./.github/scripts/check-pr-permissions.js')
137+
await script({github, context, core})
291138
292139
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
293140
with:

0 commit comments

Comments
 (0)