From 98013483c81e9eedeb847866a71fe23f85529984 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 6 Jul 2026 11:26:55 +0200 Subject: [PATCH] restore auto-review & command gating for private org members (#9958) * fix(ci): gate auto-review on non-fork PR not author_association (skips private members) Co-Authored-By: Claude Opus 4.8 (1M context) * fix(ci): authorize private org members for command workflows via app-token gate Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/check-write-access.yml | 66 ++++++++++++++++++++++++ .github/workflows/claude-plan.yml | 28 ++++++---- .github/workflows/claude.yml | 28 ++++++---- .github/workflows/codex-pr-review.yml | 8 ++- .github/workflows/git-commands.yaml | 22 ++++++-- .github/workflows/pi-pr-review.yml | 8 ++- .github/workflows/pr-ready-review.yml | 7 ++- .github/workflows/pr-review-commands.yml | 38 +++++++++++--- 8 files changed, 166 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/check-write-access.yml diff --git a/.github/workflows/check-write-access.yml b/.github/workflows/check-write-access.yml new file mode 100644 index 0000000000..5e9494e74a --- /dev/null +++ b/.github/workflows/check-write-access.yml @@ -0,0 +1,66 @@ +name: Check Write Access + +# Authorizes a user to trigger privileged command workflows (/review, /ai, /plan, +# /updatesqlx, ...). The webhook author_association reports PRIVATE org members as +# CONTRIBUTOR/NONE (only public members show as MEMBER), so command jobs can't gate on +# it alone. This mints the internal GitHub App token — which can see private members — +# and confirms the user is a member or has write access to the repo. The app token is +# minted fresh per run, so unlike the old ORG_ACCESS_TOKEN PAT it never expires. + +on: + workflow_call: + inputs: + username: + required: true + type: string + description: 'The user whose access to verify' + trusted_bot: + required: false + type: string + default: 'windmill-internal-app[bot]' + description: 'A bot login that is always authorized' + outputs: + authorized: + description: 'true if the user is the trusted bot, an org member, or has repo write access' + value: ${{ jobs.check.outputs.authorized }} + +jobs: + check: + runs-on: ubuntu-latest + outputs: + authorized: ${{ steps.check.outputs.authorized }} + steps: + - name: Mint internal app token + id: app + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.INTERNAL_APP_ID }} + private-key: ${{ secrets.INTERNAL_APP_KEY }} + owner: ${{ github.repository_owner }} + + - name: Resolve authorization + id: check + env: + GH_TOKEN: ${{ steps.app.outputs.token }} + USERNAME: ${{ inputs.username }} + TRUSTED_BOT: ${{ inputs.trusted_bot }} + REPO: ${{ github.repository }} + run: | + if [ "$USERNAME" = "$TRUSTED_BOT" ]; then + echo "authorized=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + ORG="${REPO%%/*}" + # Org membership resolves private members too (204 = member, 404 = not). + if gh api "orgs/$ORG/members/$USERNAME" --silent 2>/dev/null; then + echo "authorized=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Fallback: effective repo permission (also covers outside collaborators). + PERM=$(gh api "repos/$REPO/collaborators/$USERNAME/permission" --jq '.permission' 2>/dev/null || echo none) + if [ "$PERM" = "admin" ] || [ "$PERM" = "write" ]; then + echo "authorized=true" >> "$GITHUB_OUTPUT" + else + echo "authorized=false" >> "$GITHUB_OUTPUT" + echo "$USERNAME is neither the trusted bot, an org member, nor a repo writer." + fi diff --git a/.github/workflows/claude-plan.yml b/.github/workflows/claude-plan.yml index e4394fe7a1..ef25554f83 100644 --- a/.github/workflows/claude-plan.yml +++ b/.github/workflows/claude-plan.yml @@ -11,18 +11,24 @@ on: types: [submitted] jobs: - claude-plan-action: + # author_association misses private org members; check-access resolves them via the + # internal app token. Both are OR'd below so public members still pass instantly. + check-access: if: | - ( - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/plan')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/plan')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '/plan')) || - (github.event_name == 'issues' && contains(github.event.issue.body, '/plan')) - ) && - ( - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association) || - (github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login) == 'windmill-internal-app[bot]' - ) + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '/plan')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/plan')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '/plan')) || + (github.event_name == 'issues' && contains(github.event.issue.body, '/plan')) + uses: ./.github/workflows/check-write-access.yml + with: + username: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }} + secrets: inherit + + claude-plan-action: + needs: [check-access] + if: | + needs.check-access.outputs.authorized == 'true' || + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association) runs-on: ubicloud-standard-4 timeout-minutes: 20 permissions: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 0a66553362..115115dac6 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -11,18 +11,24 @@ on: types: [submitted] jobs: - claude-code-action: + # author_association misses private org members; check-access resolves them via the + # internal app token. Both are OR'd below so public members still pass instantly. + check-access: if: | - ( - (github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/ai') && !startsWith(github.event.comment.body, '/ai-fast')) || - (github.event_name == 'pull_request_review_comment' && startsWith(github.event.comment.body, '/ai') && !startsWith(github.event.comment.body, '/ai-fast')) || - (github.event_name == 'pull_request_review' && startsWith(github.event.review.body, '/ai') && !startsWith(github.event.review.body, '/ai-fast')) || - (github.event_name == 'issues' && startsWith(github.event.issue.body, '/ai') && !startsWith(github.event.issue.body, '/ai-fast')) - ) && - ( - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association) || - (github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login) == 'windmill-internal-app[bot]' - ) + (github.event_name == 'issue_comment' && startsWith(github.event.comment.body, '/ai') && !startsWith(github.event.comment.body, '/ai-fast')) || + (github.event_name == 'pull_request_review_comment' && startsWith(github.event.comment.body, '/ai') && !startsWith(github.event.comment.body, '/ai-fast')) || + (github.event_name == 'pull_request_review' && startsWith(github.event.review.body, '/ai') && !startsWith(github.event.review.body, '/ai-fast')) || + (github.event_name == 'issues' && startsWith(github.event.issue.body, '/ai') && !startsWith(github.event.issue.body, '/ai-fast')) + uses: ./.github/workflows/check-write-access.yml + with: + username: ${{ github.event.comment.user.login || github.event.review.user.login || github.event.issue.user.login }} + secrets: inherit + + claude-code-action: + needs: [check-access] + if: | + needs.check-access.outputs.authorized == 'true' || + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || github.event.review.author_association || github.event.issue.author_association) runs-on: ubicloud-standard-8 permissions: contents: write diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 772e3e31ff..ea622914bc 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -35,12 +35,16 @@ jobs: codex-review: runs-on: ubicloud-standard-2 timeout-minutes: 30 + # A non-fork PR (head.repo.fork == false) can only be opened by someone with push + # access to this repo, so fork==false already enforces write access. Do NOT re-add + # an author_association gate: the pull_request webhook payload reports private org + # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently + # skips auto-review for every private member. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && - github.event.pull_request.head.repo.fork == false && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) + github.event.pull_request.head.repo.fork == false ) permissions: contents: read diff --git a/.github/workflows/git-commands.yaml b/.github/workflows/git-commands.yaml index 68b6da3da8..3443b7e649 100644 --- a/.github/workflows/git-commands.yaml +++ b/.github/workflows/git-commands.yaml @@ -5,10 +5,21 @@ on: types: [created] jobs: + # /command comments can come from anyone; author_association misses private org + # members, so check-access resolves them via the internal app token. Runs once and is + # OR'd into each job's guard (public members still pass on author_association alone). + check-access: + if: github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/') + uses: ./.github/workflows/check-write-access.yml + with: + username: ${{ github.event.comment.user.login }} + secrets: inherit + update-sqlx: + needs: [check-access] if: >- github.event.issue.pull_request && - (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || github.event.comment.user.login == 'windmill-internal-app[bot]') && + (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') && startsWith(github.event.comment.body, '/updatesqlx') runs-on: ubicloud-standard-8 permissions: @@ -137,9 +148,10 @@ jobs: }) demo: + needs: [check-access] if: >- github.event.issue.pull_request && - (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || github.event.comment.user.login == 'windmill-internal-app[bot]') && + (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') && startsWith(github.event.comment.body, '/demo') runs-on: ubicloud-standard-2 permissions: @@ -219,9 +231,10 @@ jobs: fi update-ee-ref: + needs: [check-access] if: >- github.event.issue.pull_request && - (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || github.event.comment.user.login == 'windmill-internal-app[bot]') && + (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') && startsWith(github.event.comment.body, '/eeref') runs-on: ubicloud-standard-2 permissions: @@ -307,9 +320,10 @@ jobs: }) update-docs: + needs: [check-access] if: >- github.event.issue.pull_request && - (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || github.event.comment.user.login == 'windmill-internal-app[bot]') && + (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || needs.check-access.outputs.authorized == 'true') && startsWith(github.event.comment.body, '/docs') runs-on: ubicloud-standard-2 permissions: diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index 4416e5674b..72553b0d83 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -33,12 +33,16 @@ jobs: pi-review: runs-on: ubicloud-standard-2 timeout-minutes: 30 + # A non-fork PR (head.repo.fork == false) can only be opened by someone with push + # access to this repo, so fork==false already enforces write access. Do NOT re-add + # an author_association gate: the pull_request webhook payload reports private org + # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently + # skips auto-review for every private member. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && - github.event.pull_request.head.repo.fork == false && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) + github.event.pull_request.head.repo.fork == false ) permissions: contents: read diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 263696ceaf..eb634977d1 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -32,11 +32,16 @@ concurrency: jobs: auto-review: runs-on: ubuntu-latest + # A non-fork PR (head.repo.fork == false) can only be opened by someone with push + # access to this repo, so fork==false already enforces write access. Do NOT re-add + # an author_association gate: the pull_request webhook payload reports private org + # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently + # skips auto-review for every private member. if: | github.event_name == 'workflow_call' || ( (github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) + github.event.pull_request.head.repo.fork == false ) permissions: contents: read diff --git a/.github/workflows/pr-review-commands.yml b/.github/workflows/pr-review-commands.yml index 4678e2986c..ef93274d7e 100644 --- a/.github/workflows/pr-review-commands.yml +++ b/.github/workflows/pr-review-commands.yml @@ -42,11 +42,24 @@ jobs: ;; esac - acknowledge: + # author_association misses private org members; check-access resolves them via the + # internal app token. Both are OR'd so public members still pass instantly. + check-access: needs: [parse] + if: needs.parse.outputs.command != '' + uses: ./.github/workflows/check-write-access.yml + with: + username: ${{ github.event.comment.user.login }} + secrets: inherit + + acknowledge: + needs: [parse, check-access] if: | needs.parse.outputs.command != '' && - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) + ( + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || + needs.check-access.outputs.authorized == 'true' + ) runs-on: ubuntu-latest permissions: issues: write @@ -63,9 +76,12 @@ jobs: -f content=eyes >/dev/null claude: - needs: [parse] + needs: [parse, check-access] if: | - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) && + ( + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || + needs.check-access.outputs.authorized == 'true' + ) && (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'claude') permissions: contents: read @@ -81,9 +97,12 @@ jobs: WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} codex: - needs: [parse] + needs: [parse, check-access] if: | - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) && + ( + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || + needs.check-access.outputs.authorized == 'true' + ) && (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'codex') permissions: contents: read @@ -100,9 +119,12 @@ jobs: WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} pi: - needs: [parse] + needs: [parse, check-access] if: | - contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) && + ( + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) || + needs.check-access.outputs.authorized == 'true' + ) && (needs.parse.outputs.command == 'review' || needs.parse.outputs.command == 'pi') permissions: contents: read