From 7a9da5ee4aa06eae66a0a017fa530c466d8c3a6e Mon Sep 17 00:00:00 2001 From: Jonathan LELIEVRE Date: Wed, 19 Aug 2026 12:28:06 +0200 Subject: [PATCH 1/2] feat: add reusable team-guard composite action Several repositories duplicate the same inline curl check to restrict workflows to members of an organization team. Centralize it so the guard policy can evolve in one place. Only state=active memberships pass; pending invitations are rejected. require_membership=false turns the hard failure into an is_member output for callers that gate jobs themselves. --- .github/actions/team-guard/action.yml | 88 +++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 .github/actions/team-guard/action.yml diff --git a/.github/actions/team-guard/action.yml b/.github/actions/team-guard/action.yml new file mode 100644 index 0000000..a60471f --- /dev/null +++ b/.github/actions/team-guard/action.yml @@ -0,0 +1,88 @@ +name: 'Team membership guard' +description: >- + Checks that a GitHub user is an active member of an organization team. + By default the step fails when the user is not a member, which blocks all + dependent jobs/steps. Set require_membership to false to only expose the + result as an output and let the caller decide what to do. +branding: + icon: 'shield' + color: 'red' + +inputs: + team_slug: + description: 'Slug of the team the actor must belong to (e.g. prestashop-sa)' + required: true + token: + description: 'Token with read:org scope used for the membership API call (e.g. JARVIS_TOKEN)' + required: true + actor: + description: 'GitHub login to verify. Defaults to the user who triggered the workflow.' + required: false + default: '' + org: + description: 'Organization owning the team. Defaults to the owner of the calling repository.' + required: false + default: '' + require_membership: + description: 'When true (default), the step fails if the actor is not an active team member. Set to false to only expose the is_member output.' + required: false + default: 'true' + +outputs: + is_member: + description: '"true" when the actor is an active member of the team, "false" otherwise' + value: ${{ steps.check.outputs.is_member }} + +runs: + using: 'composite' + steps: + - name: Check team membership + id: check + shell: bash + env: + TOKEN: ${{ inputs.token }} + ACTOR: ${{ inputs.actor || github.actor }} + TEAM: ${{ inputs.team_slug }} + ORG: ${{ inputs.org || github.repository_owner }} + REQUIRE: ${{ inputs.require_membership }} + run: | + fail_or_report() { + echo "is_member=false" >> "$GITHUB_OUTPUT" + if [ "$REQUIRE" = "true" ]; then + exit 1 + fi + exit 0 + } + + if [ -z "$TOKEN" ]; then + echo "::error::The token input is empty — the secret may not be configured or not accessible from the calling repository. It must be a token with read:org scope." + echo "⛔ Team guard: token is empty, cannot check membership." >> "$GITHUB_STEP_SUMMARY" + fail_or_report + fi + + API_URL="https://api.github.com/orgs/${ORG}/teams/${TEAM}/memberships/${ACTOR}" + echo "Checking membership: ${API_URL}" + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "$API_URL") + STATUS=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$STATUS" = "200" ]; then + ROLE=$(echo "$BODY" | jq -r '.role // "unknown"') + STATE=$(echo "$BODY" | jq -r '.state // "unknown"') + if [ "$STATE" = "active" ]; then + echo "is_member=true" >> "$GITHUB_OUTPUT" + echo "✅ ${ACTOR} is a member of ${ORG}/${TEAM} (role=${ROLE}, state=${STATE})." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "::error::${ACTOR} has a pending (state=${STATE}) membership in ${ORG}/${TEAM} — only active members are allowed." + echo "⛔ ${ACTOR} is not an active member of ${ORG}/${TEAM} (state=${STATE})." >> "$GITHUB_STEP_SUMMARY" + fail_or_report + fi + + echo "::error::${ACTOR} is not a member of ${ORG}/${TEAM} (HTTP ${STATUS})." + echo "⛔ ${ACTOR} is not a member of ${ORG}/${TEAM} (HTTP ${STATUS})." >> "$GITHUB_STEP_SUMMARY" + fail_or_report From f03b326a36cf2a8eb2a1ef4bb9311f25ba857782 Mon Sep 17 00:00:00 2001 From: Jonathan LELIEVRE Date: Wed, 19 Aug 2026 12:28:06 +0200 Subject: [PATCH 2/2] refactor: use team-guard action in ai-guarded-review The membership check is no longer coupled to the AI review flow. Inputs, outputs and secrets of the reusable workflow are unchanged, so callers need no update. --- .github/workflows/ai-guarded-review.yml | 48 +++++-------------------- 1 file changed, 9 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ai-guarded-review.yml b/.github/workflows/ai-guarded-review.yml index 1084bce..39d1a74 100644 --- a/.github/workflows/ai-guarded-review.yml +++ b/.github/workflows/ai-guarded-review.yml @@ -89,48 +89,18 @@ jobs: needs: check-label if: needs.check-label.outputs.already-reviewed != 'true' outputs: - is-member: ${{ steps.check.outputs.is-member }} + is-member: ${{ steps.check.outputs.is_member }} steps: - name: Check team membership id: check - env: - TOKEN: ${{ secrets.org-read-token }} - ACTOR: ${{ inputs.actor }} - TEAM: ${{ inputs.team-slug }} - run: | - if [ -z "$TOKEN" ]; then - echo "::error::org-read-token secret is empty — the secret may not be configured or not accessible from the calling repository." - echo "⛔ **Debug:** org-read-token secret is empty. Check that the secret exists and is accessible." >> "$GITHUB_STEP_SUMMARY" - echo "is-member=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - TOKEN_PREFIX="${TOKEN:0:4}" - echo "::debug::Token is set (starts with ${TOKEN_PREFIX}…, length=${#TOKEN})" - echo "📋 **Debug:** Token is set (length=${#TOKEN}, prefix=${TOKEN_PREFIX}…)" >> "$GITHUB_STEP_SUMMARY" - - API_URL="https://api.github.com/orgs/PrestaShop/teams/$TEAM/memberships/$ACTOR" - echo "📋 **Debug:** Calling $API_URL" >> "$GITHUB_STEP_SUMMARY" - - RESPONSE=$(curl -s -w "\n%{http_code}" \ - -H "Authorization: Bearer $TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "$API_URL") - STATUS=$(echo "$RESPONSE" | tail -1) - BODY=$(echo "$RESPONSE" | sed '$d') - - echo "📋 **Debug:** HTTP status=$STATUS" >> "$GITHUB_STEP_SUMMARY" - - if [ "$STATUS" = "200" ]; then - ROLE=$(echo "$BODY" | jq -r '.role // "unknown"') - STATE=$(echo "$BODY" | jq -r '.state // "unknown"') - echo "📋 **Debug:** Membership role=$ROLE, state=$STATE" >> "$GITHUB_STEP_SUMMARY" - echo "is-member=true" >> "$GITHUB_OUTPUT" - echo "✅ $ACTOR is a member of $TEAM (role=$ROLE, state=$STATE) — Claude review will run." >> "$GITHUB_STEP_SUMMARY" - else - echo "📋 **Debug:** Response body: $BODY" >> "$GITHUB_STEP_SUMMARY" - echo "is-member=false" >> "$GITHUB_OUTPUT" - echo "⛔ $ACTOR is not a member of $TEAM (HTTP $STATUS) — skipping Claude review." >> "$GITHUB_STEP_SUMMARY" - fi + uses: PrestaShop/.github/.github/actions/team-guard@master + with: + team_slug: ${{ inputs.team-slug }} + actor: ${{ inputs.actor }} + org: PrestaShop + token: ${{ secrets.org-read-token }} + # The gate job decides what to do with the result, so do not fail here + require_membership: false # Step 3: Evaluate guard gate: