Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 73 additions & 15 deletions .github/workflows/functional-test-cloud.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,22 +91,72 @@ env:
RADIUS_QPS_AND_BURST: "800"

jobs:
# Approval gate for external contributors. This job uses GitHub Environment protection
# Trust check for pull_request_target events. Determines whether the PR author
# is a trusted contributor (org member or same-repo push) or an external contributor.
#
# Trust is determined by:
# 1. Same-repo PR (head repo == base repo): trusted (only users with write access
# can push branches to the repo).
# 2. Fork PR + org member: trusted (checked via GitHub API using app token).
# 3. Fork PR + non-member: external (requires approval).
#
# NOTE: We do NOT rely on github.event.pull_request.author_association because
# webhook payloads report incorrect values for org members with private
# membership visibility (returns CONTRIBUTOR instead of MEMBER).
check-trust:
name: Check Trust
runs-on: ubuntu-24.04
timeout-minutes: 5
if: github.event_name == 'pull_request_target'
outputs:
is-external: ${{ steps.check.outputs.is-external }}
permissions: {}
steps:
- name: Generate App Token
id: app-token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0
with:
app-id: ${{ vars.FUNCTIONAL_TEST_APP_ID }}
private-key: ${{ secrets.FUNCTIONAL_TEST_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
permission-members: read
Comment thread
sylvainsf marked this conversation as resolved.

Comment thread
sylvainsf marked this conversation as resolved.
- name: Determine trust level
id: check
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
ORG: ${{ github.repository_owner }}
run: |
# Same-repo PRs are always trusted (requires write access to push branches)
if [ "${HEAD_REPO}" = "${BASE_REPO}" ]; then
echo "Same-repo PR from ${PR_AUTHOR} — trusted"
echo "is-external=false" >> "${GITHUB_OUTPUT}"
exit 0
fi

# Fork PR: check if the author is an org member via GitHub API.
# Uses app token which can read org membership regardless of visibility settings.
# gh api returns exit code 0 for 204 (member) and non-zero for 404/302 (not a member).
if gh api "orgs/${ORG}/members/${PR_AUTHOR}" --silent 2>/dev/null; then
echo "Fork PR from org member ${PR_AUTHOR} — trusted"
Comment thread
sylvainsf marked this conversation as resolved.
echo "is-external=false" >> "${GITHUB_OUTPUT}"
else
echo "Fork PR from ${PR_AUTHOR} — external"
echo "is-external=true" >> "${GITHUB_OUTPUT}"
fi

# Approval gate for external contributors. Uses GitHub Environment protection
# to require manual approval before running tests on PRs from non-members.
# - Org members (OWNER, MEMBER, COLLABORATOR): Tests run immediately
# - Dependabot: Tests run immediately
# - External contributors: Requires approval via GitHub UI button
approval-gate:
name: Approval Gate
needs: [check-trust]
runs-on: ubuntu-24.04
timeout-minutes: 5
# Only run for external contributors on pull_request_target events.
# Trusted users (dependabot, org members) skip this job entirely.
# External contributors require approval via 'external-contributor-approval' environment.
if: |
github.event_name == 'pull_request_target' &&
github.actor != 'dependabot[bot]' &&
!contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
needs.check-trust.outputs.is-external == 'true'
environment: external-contributor-approval
permissions: {}
steps:
Expand All @@ -115,12 +165,17 @@ jobs:

setup:
name: Setup
needs: [approval-gate]
# Run for all events. For PRs, approval-gate either succeeds (external contributors approved)
# or is skipped (trusted users like org members and dependabot).
needs: [check-trust, approval-gate]
# Run for all events. For PRs:
# - check-trust determines if the author is external
# - approval-gate runs only for external contributors and requires manual approval
# - If check-trust or approval-gate are skipped (non-PR events), setup proceeds
# For pull_request_target, require approval-gate to be 'success' or 'skipped' — block
# on 'cancelled' (rejected approval) to prevent running PR code with secrets.
if: |
always() &&
(github.event_name != 'pull_request_target' || needs.approval-gate.result == 'success' || needs.approval-gate.result == 'skipped') &&
!cancelled() &&
(needs.check-trust.result == 'success' || needs.check-trust.result == 'skipped') &&
(needs.approval-gate.result == 'success' || needs.approval-gate.result == 'skipped') &&
(github.event_name != 'schedule' || github.repository == vars.RADIUS_REPOSITORY)
runs-on: ubuntu-24.04
timeout-minutes: 5
Expand All @@ -146,6 +201,9 @@ jobs:
echo "Event Name: ${{ github.event_name }}"
echo "Actor: ${{ github.actor }}"
echo "Author Association: ${{ github.event.pull_request.author_association }}"
echo "PR Head Repo: ${{ github.event.pull_request.head.repo.full_name }}"
echo "PR Base Repo: ${{ github.event.pull_request.base.repo.full_name }}"
echo "Is Fork: ${{ github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name }}"

- name: Set up checkout target (scheduled)
if: github.event_name == 'schedule'
Expand Down
Loading