feat: fetch GitHub App credentials from AWS Secrets Manager via OIDC #516
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Dogfoods the PR review workflow on this repo's own PRs. | |
| # Instead of calling the reusable review-pr.yml (which uses the published | |
| # composite action), this workflow inlines the jobs and points at ./review-pr | |
| # so that PRs changing the review logic test themselves. | |
| name: Self PR Review | |
| on: | |
| issue_comment: | |
| types: [created] | |
| pull_request_review_comment: | |
| types: [created] | |
| pull_request: | |
| types: [ready_for_review, opened] | |
| workflow_run: | |
| workflows: ["PR Review Trigger"] | |
| types: [completed] | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| checks: write | |
| actions: read | |
| id-token: write | |
| jobs: | |
| # ========================================================================== | |
| # AUTOMATIC REVIEW FOR ORG MEMBERS | |
| # Triggers when a PR is marked ready for review or opened (non-draft) | |
| # Supports two trigger paths: | |
| # 1. pull_request event (same-repo branches only — fork PRs lack secret access) | |
| # 2. workflow_run event (fork PRs via pr-review-trigger.yml → workflow_run pattern) | |
| # Only runs for members of the docker org. | |
| # ========================================================================== | |
| auto-review: | |
| if: | | |
| ( | |
| github.event_name == 'pull_request' && | |
| !github.event.pull_request.draft && | |
| github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name | |
| ) || ( | |
| github.event_name == 'workflow_run' && | |
| github.event.workflow_run.name == 'PR Review Trigger' && | |
| github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.head_repository.full_name != github.repository | |
| ) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Configure AWS credentials | |
| uses: aws-actions/configure-aws-credentials@v4 | |
| with: | |
| role-to-assume: TODO_ROLE_ARN | |
| aws-region: us-east-1 | |
| - name: Fetch GitHub App credentials from Secrets Manager | |
| id: app-credentials | |
| shell: bash | |
| run: | | |
| SECRET=$(aws secretsmanager get-secret-value \ | |
| --secret-id docker-agent-action/github-app \ | |
| --query SecretString --output text) | |
| APP_ID=$(echo "$SECRET" | jq -r .app_id) | |
| ORG_TOKEN=$(echo "$SECRET" | jq -r .org_membership_token) | |
| PRIVATE_KEY=$(echo "$SECRET" | jq -r .private_key) | |
| echo "::add-mask::$APP_ID" | |
| echo "::add-mask::$ORG_TOKEN" | |
| echo "::add-mask::$PRIVATE_KEY" | |
| echo "app-id=$APP_ID" >> $GITHUB_OUTPUT | |
| echo "org-membership-token=$ORG_TOKEN" >> $GITHUB_OUTPUT | |
| DELIM="$(openssl rand -hex 8)" | |
| { echo "private-key<<$DELIM"; echo "$PRIVATE_KEY"; echo "$DELIM"; } >> $GITHUB_OUTPUT | |
| # For workflow_run events (fork PRs), download the artifact saved by pr-review-trigger.yml | |
| # to get the PR number. For pull_request events, read it directly from the event payload. | |
| - name: Get PR number | |
| id: get-pr | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| EVENT_NAME: ${{ github.event_name }} | |
| EVENT_PR_NUMBER: ${{ github.event.pull_request.number }} | |
| WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| if [ "$EVENT_NAME" = "pull_request" ]; then | |
| echo "pr-number=$EVENT_PR_NUMBER" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| mkdir -p /tmp/trigger-metadata | |
| if ! gh run download "$WORKFLOW_RUN_ID" -n "pr-review-trigger-metadata-$WORKFLOW_RUN_ID" -D /tmp/trigger-metadata --repo "$REPO" 2>/tmp/download_error.log; then | |
| if grep -qi "no artifact" /tmp/download_error.log; then | |
| echo "⏭️ No trigger metadata artifact found — skipping" | |
| else | |
| echo "::warning::Failed to download trigger metadata: $(cat /tmp/download_error.log)" | |
| fi | |
| echo "pr-number=" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| PR_NUMBER=$(cat /tmp/trigger-metadata/pr-number | tr -d '[:space:]') | |
| if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || [ "$PR_NUMBER" -lt 1 ]; then | |
| echo "::error::Invalid PR number: $PR_NUMBER" | |
| exit 1 | |
| fi | |
| echo "pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT | |
| echo "✅ Got PR number from trigger metadata: #$PR_NUMBER" | |
| - name: Check if PR author is org member | |
| id: membership | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| github-token: ${{ steps.app-credentials.outputs.org-membership-token }} | |
| script: | | |
| const org = 'docker'; | |
| // For workflow_run events, fetch PR author and draft status via API | |
| // since github.event.pull_request is not available in that context | |
| let username; | |
| if (context.eventName === 'workflow_run') { | |
| const prNumber = parseInt('${{ steps.get-pr.outputs.pr-number }}', 10); | |
| if (!prNumber) { | |
| core.setOutput('is_member', 'false'); | |
| console.log('⏭️ No PR number — skipping auto-review'); | |
| return; | |
| } | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber | |
| }); | |
| if (pr.draft) { | |
| core.setOutput('is_member', 'false'); | |
| console.log(`⏭️ PR #${prNumber} is a draft — skipping auto-review`); | |
| return; | |
| } | |
| username = pr.user.login; | |
| } else { | |
| username = context.payload.pull_request.user.login; | |
| } | |
| try { | |
| await github.rest.orgs.checkMembershipForUser({ | |
| org: org, | |
| username: username | |
| }); | |
| core.setOutput('is_member', 'true'); | |
| console.log(`✅ ${username} is a ${org} org member - proceeding with auto-review`); | |
| } catch (error) { | |
| if (error.status === 404 || error.status === 302) { | |
| core.setOutput('is_member', 'false'); | |
| console.log(`⏭️ ${username} is not a ${org} org member - skipping auto-review`); | |
| } else if (error.status === 401) { | |
| core.setFailed( | |
| '❌ CAGENT_ORG_MEMBERSHIP_TOKEN secret is missing or invalid.\n\n' + | |
| `This secret is required to check ${org} org membership for auto-reviews.\n\n` + | |
| 'To fix this:\n' + | |
| '1. Create a classic PAT with read:org scope at https://github.com/settings/tokens/new\n' + | |
| '2. Add it as an org secret named CAGENT_ORG_MEMBERSHIP_TOKEN' | |
| ); | |
| } else { | |
| core.setFailed(`Failed to check org membership: ${error.message}`); | |
| } | |
| } | |
| - name: Checkout PR head | |
| if: steps.membership.outputs.is_member == 'true' | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 | |
| ref: refs/pull/${{ steps.get-pr.outputs.pr-number }}/head | |
| # Generate GitHub App token for custom app identity (optional - falls back to github.token) | |
| - name: Generate GitHub App token | |
| if: steps.membership.outputs.is_member == 'true' | |
| id: app-token | |
| continue-on-error: true # Don't fail workflow if token generation fails | |
| uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 | |
| with: | |
| app_id: ${{ steps.app-credentials.outputs.app-id }} | |
| private_key: ${{ steps.app-credentials.outputs.private-key }} | |
| - name: Run PR Review | |
| if: steps.membership.outputs.is_member == 'true' | |
| id: run-review | |
| continue-on-error: true # Don't fail the calling workflow if the review errors | |
| uses: ./review-pr | |
| with: | |
| pr-number: ${{ steps.get-pr.outputs.pr-number }} | |
| github-token: ${{ steps.app-token.outputs.token || github.token }} | |
| anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | |
| google-api-key: ${{ secrets.GOOGLE_API_KEY }} | |
| aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} | |
| xai-api-key: ${{ secrets.XAI_API_KEY }} | |
| nebius-api-key: ${{ secrets.NEBIUS_API_KEY }} | |
| mistral-api-key: ${{ secrets.MISTRAL_API_KEY }} | |
| skip-auth: "true" # Org membership already verified above | |
| # ========================================================================== | |
| # MANUAL REVIEW PIPELINE | |
| # Triggers when someone comments /review on a PR | |
| # ========================================================================== | |
| manual-review: | |
| if: | | |
| github.event.issue.pull_request && | |
| startsWith(github.event.comment.body, '/review') && | |
| (github.event.comment.user.type != 'Bot' || github.event.comment.user.login == 'docker-agent[bot]') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Configure AWS credentials | |
| uses: aws-actions/configure-aws-credentials@v4 | |
| with: | |
| role-to-assume: TODO_ROLE_ARN | |
| aws-region: us-east-1 | |
| - name: Fetch GitHub App credentials from Secrets Manager | |
| id: app-credentials | |
| shell: bash | |
| run: | | |
| SECRET=$(aws secretsmanager get-secret-value \ | |
| --secret-id docker-agent-action/github-app \ | |
| --query SecretString --output text) | |
| APP_ID=$(echo "$SECRET" | jq -r .app_id) | |
| ORG_TOKEN=$(echo "$SECRET" | jq -r .org_membership_token) | |
| PRIVATE_KEY=$(echo "$SECRET" | jq -r .private_key) | |
| echo "::add-mask::$APP_ID" | |
| echo "::add-mask::$ORG_TOKEN" | |
| echo "::add-mask::$PRIVATE_KEY" | |
| echo "app-id=$APP_ID" >> $GITHUB_OUTPUT | |
| echo "org-membership-token=$ORG_TOKEN" >> $GITHUB_OUTPUT | |
| DELIM="$(openssl rand -hex 8)" | |
| { echo "private-key<<$DELIM"; echo "$PRIVATE_KEY"; echo "$DELIM"; } >> $GITHUB_OUTPUT | |
| - name: Check if commenter is org member | |
| id: membership | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| github-token: ${{ steps.app-credentials.outputs.org-membership-token }} | |
| script: | | |
| const org = 'docker'; | |
| const username = context.payload.comment.user.login; | |
| const userType = context.payload.comment.user.type; | |
| // Allow trusted bot to bypass org membership check | |
| if (userType === 'Bot') { | |
| core.setOutput('is_member', 'true'); | |
| console.log(`✅ ${username} is a Bot — allowing /review command`); | |
| return; | |
| } | |
| try { | |
| await github.rest.orgs.checkMembershipForUser({ org, username }); | |
| core.setOutput('is_member', 'true'); | |
| console.log(`✅ ${username} is a ${org} org member — proceeding with manual review`); | |
| } catch (error) { | |
| if (error.status === 404 || error.status === 302) { | |
| core.setOutput('is_member', 'false'); | |
| console.log(`⏭️ ${username} is not a ${org} org member — skipping manual review`); | |
| } else if (error.status === 401) { | |
| core.warning( | |
| '❌ CAGENT_ORG_MEMBERSHIP_TOKEN secret is missing or invalid.\n\n' + | |
| `This secret is required to check ${org} org membership.\n\n` + | |
| 'To fix this:\n' + | |
| '1. Create a classic PAT with read:org scope at https://github.com/settings/tokens/new\n' + | |
| '2. Add it as an org secret named CAGENT_ORG_MEMBERSHIP_TOKEN' | |
| ); | |
| core.setOutput('is_member', 'false'); | |
| } else { | |
| core.warning(`Failed to check org membership: ${error.message}`); | |
| core.setOutput('is_member', 'false'); | |
| } | |
| } | |
| # Generate GitHub App token first so the check run is created under the app's identity | |
| # (prevents GitHub from nesting it under unrelated pull_request-triggered workflows) | |
| - name: Generate GitHub App token | |
| if: steps.membership.outputs.is_member == 'true' | |
| id: app-token | |
| continue-on-error: true # Don't fail workflow if token generation fails | |
| uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2 | |
| with: | |
| app_id: ${{ steps.app-credentials.outputs.app-id }} | |
| private_key: ${{ steps.app-credentials.outputs.private-key }} | |
| - name: Create check run | |
| if: steps.membership.outputs.is_member == 'true' | |
| id: create-check | |
| continue-on-error: true # Don't fail if checks: write permission is missing | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| env: | |
| APP_TOKEN: ${{ steps.app-token.outputs.token }} | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token || github.token }} | |
| script: | | |
| const prNumber = context.issue.number; | |
| const { data: pr } = await github.rest.pulls.get({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: prNumber | |
| }); | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; | |
| const { data: check } = await github.rest.checks.create({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| name: 'PR Review', | |
| head_sha: pr.head.sha, | |
| status: 'in_progress', | |
| started_at: new Date().toISOString(), | |
| details_url: runUrl | |
| }); | |
| core.setOutput('check-id', check.id); | |
| - name: Checkout PR head | |
| if: steps.membership.outputs.is_member == 'true' | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | |
| with: | |
| fetch-depth: 0 | |
| ref: refs/pull/${{ github.event.issue.number }}/head | |
| - name: Run PR Review | |
| if: steps.membership.outputs.is_member == 'true' | |
| id: run-review | |
| continue-on-error: true # Don't fail the calling workflow if the review errors | |
| uses: ./review-pr | |
| with: | |
| pr-number: ${{ github.event.issue.number }} | |
| comment-id: ${{ github.event.comment.id }} | |
| github-token: ${{ steps.app-token.outputs.token || github.token }} | |
| trusted-bot-app-id: ${{ steps.app-credentials.outputs.app-id }} | |
| anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} | |
| openai-api-key: ${{ secrets.OPENAI_API_KEY }} | |
| google-api-key: ${{ secrets.GOOGLE_API_KEY }} | |
| aws-bearer-token-bedrock: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} | |
| xai-api-key: ${{ secrets.XAI_API_KEY }} | |
| nebius-api-key: ${{ secrets.NEBIUS_API_KEY }} | |
| mistral-api-key: ${{ secrets.MISTRAL_API_KEY }} | |
| skip-auth: "true" # Org membership already verified above | |
| - name: Update check run | |
| if: always() && steps.create-check.outputs.check-id != '' | |
| uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| env: | |
| CHECK_ID: ${{ steps.create-check.outputs.check-id }} | |
| JOB_STATUS: ${{ job.status }} | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token || github.token }} | |
| script: | | |
| const conclusion = process.env.JOB_STATUS === 'cancelled' ? 'cancelled' : process.env.JOB_STATUS === 'success' ? 'success' : 'failure'; | |
| try { | |
| await github.rest.checks.update({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| check_run_id: parseInt(process.env.CHECK_ID, 10), | |
| status: 'completed', | |
| conclusion: conclusion, | |
| completed_at: new Date().toISOString() | |
| }); | |
| } catch (error) { | |
| core.warning(`Failed to update check run: ${error.message}`); | |
| } | |
| # ========================================================================== | |
| # CAPTURE FEEDBACK | |
| # Saves feedback data as an artifact for the workflow_run-triggered | |
| # reply-to-feedback workflow. This job intentionally avoids using secrets. | |
| # The reply-to-feedback.yml workflow runs via workflow_run in the base | |
| # repo context with full permissions and secrets. | |
| # ========================================================================== | |
| capture-feedback: | |
| if: github.event_name == 'pull_request_review_comment' && github.event.comment.in_reply_to_id | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Check if reply is to agent comment | |
| id: check | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| PARENT_ID: ${{ github.event.comment.in_reply_to_id }} | |
| REPO: ${{ github.repository }} | |
| run: | | |
| if [ -z "$PARENT_ID" ]; then | |
| echo "is_agent=false" >> $GITHUB_OUTPUT | |
| echo "⏭️ Not a reply comment, skipping" | |
| exit 0 | |
| fi | |
| parent=$(gh api "repos/$REPO/pulls/comments/$PARENT_ID" 2>/dev/null || echo "{}") | |
| body=$(echo "$parent" | jq -r '.body // ""') | |
| parent_user_type=$(echo "$parent" | jq -r '.user.type // ""') | |
| # Defense-in-depth: verify the root comment was posted by a Bot (agent) AND | |
| # contains the review marker but NOT the reply marker. | |
| if [ "$parent_user_type" = "Bot" ] && \ | |
| echo "$body" | grep -q "<!-- cagent-review -->" && \ | |
| ! echo "$body" | grep -q "<!-- cagent-review-reply -->"; then | |
| echo "is_agent=true" >> $GITHUB_OUTPUT | |
| # Extract file path and line from the root comment for context | |
| echo "file_path=$(echo "$parent" | jq -r '.path // ""')" >> $GITHUB_OUTPUT | |
| echo "line=$(echo "$parent" | jq -r '.line // .original_line // ""')" >> $GITHUB_OUTPUT | |
| echo "✅ Reply is to an agent review comment" | |
| else | |
| echo "is_agent=false" >> $GITHUB_OUTPUT | |
| echo "⏭️ Not a reply to agent comment, skipping" | |
| fi | |
| - name: Save feedback data and metadata | |
| if: steps.check.outputs.is_agent == 'true' | |
| shell: bash | |
| env: | |
| COMMENT_JSON: ${{ toJSON(github.event.comment) }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| REPO: ${{ github.repository }} | |
| PARENT_ID: ${{ github.event.comment.in_reply_to_id }} | |
| COMMENT_ID: ${{ github.event.comment.id }} | |
| AUTHOR: ${{ github.event.comment.user.login }} | |
| AUTHOR_TYPE: ${{ github.event.comment.user.type }} | |
| AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} | |
| FILE_PATH: ${{ steps.check.outputs.file_path }} | |
| LINE: ${{ steps.check.outputs.line }} | |
| run: | | |
| mkdir -p feedback | |
| echo "$COMMENT_JSON" > feedback/feedback.json | |
| # Save metadata for the workflow_run reply job | |
| # author_association is saved for logging (not used for auth — see reply-to-feedback.yml) | |
| jq -n \ | |
| --arg pr_number "$PR_NUMBER" \ | |
| --arg repo "$REPO" \ | |
| --arg parent_comment_id "$PARENT_ID" \ | |
| --arg comment_id "$COMMENT_ID" \ | |
| --arg author "$AUTHOR" \ | |
| --arg author_type "$AUTHOR_TYPE" \ | |
| --arg author_association "$AUTHOR_ASSOCIATION" \ | |
| --arg file_path "$FILE_PATH" \ | |
| --arg line "$LINE" \ | |
| --argjson is_agent_comment true \ | |
| '{ | |
| pr_number: $pr_number, | |
| repo: $repo, | |
| parent_comment_id: $parent_comment_id, | |
| comment_id: $comment_id, | |
| author: $author, | |
| author_type: $author_type, | |
| author_association: $author_association, | |
| file_path: $file_path, | |
| line: $line, | |
| is_agent_comment: $is_agent_comment | |
| }' > feedback/metadata.json | |
| echo "📦 Saved feedback data and metadata for workflow_run processing" | |
| - name: Upload feedback artifact | |
| if: steps.check.outputs.is_agent == 'true' | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: pr-review-feedback | |
| path: feedback/ | |
| retention-days: 90 | |