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) <noreply@anthropic.com>

* fix(ci): authorize private org members for command workflows via app-token gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-06 11:26:55 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ea19cc9dc4
commit 98013483c8
8 changed files with 166 additions and 39 deletions
+66
View File
@@ -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
+17 -11
View File
@@ -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:
+17 -11
View File
@@ -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
+6 -2
View File
@@ -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
+18 -4
View File
@@ -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:
+6 -2
View File
@@ -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
+6 -1
View File
@@ -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
+30 -8
View File
@@ -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