diff --git a/.changeset/patch-add-github-mcp-app-token.md b/.changeset/patch-add-github-mcp-app-token.md new file mode 100644 index 0000000000..7a308ea4d8 --- /dev/null +++ b/.changeset/patch-add-github-mcp-app-token.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Add GitHub App token minting for the GitHub MCP server tooling and workflows. diff --git a/.github/workflows/artifacts-summary.lock.yml b/.github/workflows/artifacts-summary.lock.yml index 3248180ae7..29ae63dff2 100644 --- a/.github/workflows/artifacts-summary.lock.yml +++ b/.github/workflows/artifacts-summary.lock.yml @@ -897,7 +897,7 @@ jobs: with: destination: /opt/gh-aw/actions - name: Generate GitHub App token - id: app-token + id: safe-outputs-app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.APP_ID }} @@ -939,7 +939,7 @@ jobs: GH_AW_NOOP_MAX: 1 GH_AW_WORKFLOW_NAME: "Artifacts Summary" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -952,7 +952,7 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Artifacts Summary" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -968,7 +968,7 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -986,16 +986,16 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); await main(); - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' + if: always() && steps.safe-outputs-app-token.outputs.token != '' env: - TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | echo "Revoking GitHub App installation token..." # GitHub CLI will auth with the token being revoked. @@ -1195,7 +1195,7 @@ jobs: find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - name: Generate GitHub App token - id: app-token + id: safe-outputs-app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.APP_ID }} @@ -1212,16 +1212,16 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_discussion\":{\"category\":\"artifacts\",\"close_older_discussions\":true,\"expires\":168,\"max\":1},\"missing_data\":{},\"missing_tool\":{}}" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' + if: always() && steps.safe-outputs-app-token.outputs.token != '' env: - TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | echo "Revoking GitHub App installation token..." # GitHub CLI will auth with the token being revoked. diff --git a/.github/workflows/changeset.lock.yml b/.github/workflows/changeset.lock.yml index 305632b740..eeb0773f75 100644 --- a/.github/workflows/changeset.lock.yml +++ b/.github/workflows/changeset.lock.yml @@ -1082,7 +1082,7 @@ jobs: with: destination: /opt/gh-aw/actions - name: Generate GitHub App token - id: app-token + id: safe-outputs-app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.APP_ID }} @@ -1124,7 +1124,7 @@ jobs: GH_AW_NOOP_MAX: 1 GH_AW_WORKFLOW_NAME: "Changeset Generator" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1137,7 +1137,7 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_WORKFLOW_NAME: "Changeset Generator" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1153,7 +1153,7 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1171,16 +1171,16 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); await main(); - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' + if: always() && steps.safe-outputs-app-token.outputs.token != '' env: - TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | echo "Revoking GitHub App installation token..." # GitHub CLI will auth with the token being revoked. @@ -1402,7 +1402,7 @@ jobs: name: agent-artifacts path: /tmp/gh-aw/ - name: Generate GitHub App token - id: app-token + id: safe-outputs-app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.APP_ID }} @@ -1417,7 +1417,7 @@ jobs: if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')) uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - token: ${{ steps.app-token.outputs.token }} + token: ${{ steps.safe-outputs-app-token.outputs.token }} persist-credentials: false fetch-depth: 1 - name: Configure Git credentials @@ -1425,7 +1425,7 @@ jobs: env: REPO_NAME: ${{ github.repository }} SERVER_URL: ${{ github.server_url }} - GIT_TOKEN: ${{ steps.app-token.outputs.token }} + GIT_TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | git config --global user.email "github-actions[bot]@users.noreply.github.com" git config --global user.name "github-actions[bot]" @@ -1440,16 +1440,16 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"missing_data\":{},\"missing_tool\":{},\"push_to_pull_request_branch\":{\"base_branch\":\"${{ github.ref_name }}\",\"commit_title_suffix\":\" [skip-ci]\",\"if_no_changes\":\"warn\",\"max_patch_size\":1024},\"update_pull_request\":{\"allow_body\":true,\"allow_title\":false,\"default_operation\":\"append\",\"max\":1}}" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' + if: always() && steps.safe-outputs-app-token.outputs.token != '' env: - TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | echo "Revoking GitHub App installation token..." # GitHub CLI will auth with the token being revoked. diff --git a/.github/workflows/daily-file-diet.lock.yml b/.github/workflows/daily-file-diet.lock.yml index 0cccb03606..e23e1b3df4 100644 --- a/.github/workflows/daily-file-diet.lock.yml +++ b/.github/workflows/daily-file-diet.lock.yml @@ -1122,7 +1122,7 @@ jobs: with: destination: /opt/gh-aw/actions - name: Generate GitHub App token - id: app-token + id: safe-outputs-app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.APP_ID }} @@ -1165,7 +1165,7 @@ jobs: GH_AW_WORKFLOW_NAME: "Daily File Diet" GH_AW_TRACKER_ID: "daily-file-diet" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1179,7 +1179,7 @@ jobs: GH_AW_WORKFLOW_NAME: "Daily File Diet" GH_AW_TRACKER_ID: "daily-file-diet" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1196,7 +1196,7 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1215,16 +1215,16 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); await main(); - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' + if: always() && steps.safe-outputs-app-token.outputs.token != '' env: - TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | echo "Revoking GitHub App installation token..." # GitHub CLI will auth with the token being revoked. @@ -1468,7 +1468,7 @@ jobs: find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - name: Generate GitHub App token - id: app-token + id: safe-outputs-app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.APP_ID }} @@ -1485,16 +1485,16 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"refactoring\",\"code-health\",\"automated-analysis\",\"cookie\"],\"max\":1,\"title_prefix\":\"[file-diet] \"},\"missing_data\":{},\"missing_tool\":{}}" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' + if: always() && steps.safe-outputs-app-token.outputs.token != '' env: - TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | echo "Revoking GitHub App installation token..." # GitHub CLI will auth with the token being revoked. diff --git a/.github/workflows/daily-testify-uber-super-expert.lock.yml b/.github/workflows/daily-testify-uber-super-expert.lock.yml index 79e676f1b3..2e6e5869b4 100644 --- a/.github/workflows/daily-testify-uber-super-expert.lock.yml +++ b/.github/workflows/daily-testify-uber-super-expert.lock.yml @@ -1396,7 +1396,7 @@ jobs: with: destination: /opt/gh-aw/actions - name: Generate GitHub App token - id: app-token + id: safe-outputs-app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.APP_ID }} @@ -1439,7 +1439,7 @@ jobs: GH_AW_WORKFLOW_NAME: "Daily Testify Uber Super Expert" GH_AW_TRACKER_ID: "daily-testify-uber-super-expert" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1453,7 +1453,7 @@ jobs: GH_AW_WORKFLOW_NAME: "Daily Testify Uber Super Expert" GH_AW_TRACKER_ID: "daily-testify-uber-super-expert" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1470,7 +1470,7 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.agent.outputs.secret_verification_result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); @@ -1489,16 +1489,16 @@ jobs: GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.result }} with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/notify_comment_error.cjs'); await main(); - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' + if: always() && steps.safe-outputs-app-token.outputs.token != '' env: - TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | echo "Revoking GitHub App installation token..." # GitHub CLI will auth with the token being revoked. @@ -1803,7 +1803,7 @@ jobs: find "/tmp/gh-aw/safeoutputs/" -type f -print echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - name: Generate GitHub App token - id: app-token + id: safe-outputs-app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 with: app-id: ${{ vars.APP_ID }} @@ -1820,16 +1820,16 @@ jobs: GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"labels\":[\"testing\",\"code-quality\",\"automated-analysis\",\"cookie\"],\"max\":1,\"title_prefix\":\"[testify-expert] \"},\"missing_data\":{},\"missing_tool\":{}}" with: - github-token: ${{ steps.app-token.outputs.token }} + github-token: ${{ steps.safe-outputs-app-token.outputs.token }} script: | const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io); const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); await main(); - name: Invalidate GitHub App token - if: always() && steps.app-token.outputs.token != '' + if: always() && steps.safe-outputs-app-token.outputs.token != '' env: - TOKEN: ${{ steps.app-token.outputs.token }} + TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }} run: | echo "Revoking GitHub App installation token..." # GitHub CLI will auth with the token being revoked. diff --git a/.github/workflows/shared/github-mcp-app.md b/.github/workflows/shared/github-mcp-app.md new file mode 100644 index 0000000000..40647e2a4d --- /dev/null +++ b/.github/workflows/shared/github-mcp-app.md @@ -0,0 +1,77 @@ +--- +tools: + github: + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + + diff --git a/.github/workflows/smoke-claude.lock.yml b/.github/workflows/smoke-claude.lock.yml index 9aca88e65b..2a88787b3e 100644 --- a/.github/workflows/smoke-claude.lock.yml +++ b/.github/workflows/smoke-claude.lock.yml @@ -24,6 +24,7 @@ # Resolved workflow manifest: # Imports: # - shared/gh.md +# - shared/github-mcp-app.md # - shared/github-queries-safe-input.md # - shared/go-make.md # - shared/mcp-pagination.md @@ -204,6 +205,19 @@ jobs: script: | const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); + - name: Generate GitHub App token + id: github-mcp-app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + github-api-url: ${{ github.api_url }} + permission-contents: read + permission-discussions: read + permission-issues: read + permission-pull-requests: read - name: Download container images run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.29.0 ghcr.io/githubnext/gh-aw-mcpg:v0.0.78 mcr.microsoft.com/playwright/mcp node:lts-alpine - name: Write Safe Outputs Config @@ -1070,7 +1084,7 @@ jobs: GH_DEBUG: 1 GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_MCP_LOCKDOWN: ${{ steps.determine-automatic-lockdown.outputs.lockdown == 'true' && '1' || '0' }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }} TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }} run: | set -eo pipefail @@ -1522,6 +1536,8 @@ jobs: + + # Smoke Test: Claude Engine Validation **IMPORTANT: Keep all outputs extremely short and concise. Use single-line responses where possible. No verbose explanations.** @@ -1753,8 +1769,9 @@ jobs: const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); await main(); env: - GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,TAVILY_API_KEY' + GH_AW_SECRET_NAMES: 'ANTHROPIC_API_KEY,APP_PRIVATE_KEY,CLAUDE_CODE_OAUTH_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN,TAVILY_API_KEY' SECRET_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + SECRET_APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} SECRET_CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} @@ -1847,6 +1864,19 @@ jobs: /tmp/gh-aw/sandbox/firewall/logs/ /tmp/gh-aw/agent-stdio.log if-no-files-found: ignore + - name: Invalidate GitHub App token + if: always() && steps.github-mcp-app-token.outputs.token != '' + env: + TOKEN: ${{ steps.github-mcp-app-token.outputs.token }} + run: | + echo "Revoking GitHub App installation token..." + # GitHub CLI will auth with the token being revoked. + gh api \ + --method DELETE \ + -H "Authorization: token $TOKEN" \ + /installation/token || echo "Token revoke may already be expired." + + echo "Token invalidation step complete." conclusion: needs: diff --git a/.github/workflows/smoke-claude.md b/.github/workflows/smoke-claude.md index ae3336e228..6a62b0302e 100644 --- a/.github/workflows/smoke-claude.md +++ b/.github/workflows/smoke-claude.md @@ -25,6 +25,7 @@ imports: - shared/reporting.md - shared/github-queries-safe-input.md - shared/go-make.md + - shared/github-mcp-app.md network: allowed: - defaults diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3ed640e89c..cde466fa0c 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2697,6 +2697,44 @@ "description": "Mount specification in format 'host:container:mode'" }, "examples": [["/data:/data:ro", "/tmp:/tmp:rw"], ["/opt:/opt:ro"]] + }, + "app": { + "type": "object", + "description": "GitHub App configuration for token minting. When configured, a GitHub App installation access token is minted at workflow start and used instead of the default token. This token overrides any custom github-token setting and provides fine-grained permissions matching the agent job requirements.", + "properties": { + "app-id": { + "type": "string", + "description": "GitHub App ID (e.g., '${{ vars.APP_ID }}'). Required to mint a GitHub App token." + }, + "private-key": { + "type": "string", + "description": "GitHub App private key (e.g., '${{ secrets.APP_PRIVATE_KEY }}'). Required to mint a GitHub App token." + }, + "owner": { + "type": "string", + "description": "Optional owner of the GitHub App installation (defaults to current repository owner if not specified)" + }, + "repositories": { + "type": "array", + "description": "Optional list of repositories to grant access to (defaults to current repository if not specified)", + "items": { + "type": "string" + } + } + }, + "required": ["app-id", "private-key"], + "additionalProperties": false, + "examples": [ + { + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}" + }, + { + "app-id": "${{ vars.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + "repositories": ["repo1", "repo2"] + } + ] } }, "additionalProperties": false, diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index 200d3a7541..a5370c3dd0 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -80,9 +80,9 @@ func (c *Compiler) buildSharedPRCheckoutSteps(data *WorkflowData) []string { var gitRemoteToken string if data.SafeOutputs.App != nil { // nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential - checkoutToken = "${{ steps.app-token.outputs.token }}" //nolint:gosec + checkoutToken = "${{ steps.safe-outputs-app-token.outputs.token }}" //nolint:gosec // nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential - gitRemoteToken = "${{ steps.app-token.outputs.token }}" + gitRemoteToken = "${{ steps.safe-outputs-app-token.outputs.token }}" } else { // nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential checkoutToken = "${{ github.token }}" diff --git a/pkg/workflow/compiler_safe_outputs_steps_test.go b/pkg/workflow/compiler_safe_outputs_steps_test.go index 28b3eed69c..ba6aa5cca2 100644 --- a/pkg/workflow/compiler_safe_outputs_steps_test.go +++ b/pkg/workflow/compiler_safe_outputs_steps_test.go @@ -192,7 +192,7 @@ func TestBuildSharedPRCheckoutSteps(t *testing.T) { CreatePullRequests: &CreatePullRequestsConfig{}, }, checkContains: []string{ - "token: ${{ steps.app-token.outputs.token }}", + "token: ${{ steps.safe-outputs-app-token.outputs.token }}", }, }, { diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index 1934173089..239a542eef 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -145,6 +145,9 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat // Add GitHub MCP lockdown detection step if needed c.generateGitHubMCPLockdownDetectionStep(yaml, data) + // Add GitHub MCP app token minting step if configured + c.generateGitHubMCPAppTokenMintingStep(yaml, data) + // Add MCP setup c.generateMCPSetup(yaml, data.Tools, engine, data) @@ -324,6 +327,9 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat // Generate single unified artifact upload with all collected paths c.generateUnifiedArtifactUpload(yaml, artifactPaths) + // Add GitHub MCP app token invalidation step if configured (runs always, even on failure) + c.generateGitHubMCPAppTokenInvalidationStep(yaml, data) + // Validate step ordering - this is a compiler check to ensure security if err := c.stepOrderTracker.ValidateStepOrdering(); err != nil { // This is a compiler bug if validation fails diff --git a/pkg/workflow/github_mcp_app_token_test.go b/pkg/workflow/github_mcp_app_token_test.go new file mode 100644 index 0000000000..c431aee3a5 --- /dev/null +++ b/pkg/workflow/github_mcp_app_token_test.go @@ -0,0 +1,212 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGitHubMCPAppTokenConfiguration tests that app configuration is correctly parsed for GitHub tool +func TestGitHubMCPAppTokenConfiguration(t *testing.T) { + compiler := NewCompiler(false, "", "1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read + issues: read # read permission for testing +strict: false # disable strict mode for testing +tools: + github: + mode: local + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + repositories: + - "repo1" + - "repo2" +--- + +# Test Workflow + +Test workflow with GitHub MCP server app configuration. +` + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + workflowData, err := compiler.ParseWorkflowFile(testFile) + require.NoError(t, err, "Failed to parse markdown content") + require.NotNil(t, workflowData.ParsedTools, "ParsedTools should not be nil") + require.NotNil(t, workflowData.ParsedTools.GitHub, "GitHub tool should be parsed") + require.NotNil(t, workflowData.ParsedTools.GitHub.App, "App configuration should be parsed") + + // Verify app configuration + assert.Equal(t, "${{ vars.APP_ID }}", workflowData.ParsedTools.GitHub.App.AppID) + assert.Equal(t, "${{ secrets.APP_PRIVATE_KEY }}", workflowData.ParsedTools.GitHub.App.PrivateKey) + assert.Equal(t, []string{"repo1", "repo2"}, workflowData.ParsedTools.GitHub.App.Repositories) +} + +// TestGitHubMCPAppTokenMintingStep tests that token minting step is generated +func TestGitHubMCPAppTokenMintingStep(t *testing.T) { + compiler := NewCompiler(false, "", "1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read + issues: read # read permission for testing +strict: false # disable strict mode for testing +tools: + github: + mode: local + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +# Test Workflow + +Test workflow with GitHub MCP app token minting. +` + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Failed to compile workflow") + + // Read the generated lock file (same name with .lock.yml extension) + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + content, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + lockContent := string(content) + + // Verify token minting step is present + assert.Contains(t, lockContent, "Generate GitHub App token", "Token minting step should be present") + assert.Contains(t, lockContent, "actions/create-github-app-token", "Should use create-github-app-token action") + assert.Contains(t, lockContent, "id: github-mcp-app-token", "Should use github-mcp-app-token as step ID") + assert.Contains(t, lockContent, "app-id: ${{ vars.APP_ID }}", "Should use configured app ID") + assert.Contains(t, lockContent, "private-key: ${{ secrets.APP_PRIVATE_KEY }}", "Should use configured private key") + + // Verify permissions are passed to the app token minting + assert.Contains(t, lockContent, "permission-contents: read", "Should include contents read permission") + assert.Contains(t, lockContent, "permission-issues: read", "Should include issues read permission") + + // Verify token invalidation step is present + assert.Contains(t, lockContent, "Invalidate GitHub App token", "Token invalidation step should be present") + assert.Contains(t, lockContent, "if: always()", "Invalidation step should always run") + assert.Contains(t, lockContent, "steps.github-mcp-app-token.outputs.token", "Should reference github-mcp-app-token output") + + // Verify the app token is used for GitHub MCP server + assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }}", "Should use app token for GitHub MCP server") +} + +// TestGitHubMCPAppTokenOverridesDefaultToken tests that app token overrides custom and default tokens +func TestGitHubMCPAppTokenOverridesDefaultToken(t *testing.T) { + compiler := NewCompiler(false, "", "1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read +tools: + github: + mode: local + github-token: ${{ secrets.CUSTOM_GITHUB_TOKEN }} + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +# Test Workflow + +Test that app token overrides custom token. +` + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Failed to compile workflow") + + // Read the generated lock file (same name with .lock.yml extension) + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + content, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + lockContent := string(content) + + // Verify app token is used (not the custom token) + assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }}", "Should use app token") + + // Verify custom token is not used when app is configured + assert.NotContains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ secrets.CUSTOM_GITHUB_TOKEN }}", "Should not use custom token when app is configured") +} + +// TestGitHubMCPAppTokenWithRemoteMode tests that app token works with remote mode +func TestGitHubMCPAppTokenWithRemoteMode(t *testing.T) { + compiler := NewCompiler(false, "", "1.0.0") + + markdown := `--- +on: issues +permissions: + contents: read +tools: + github: + mode: remote + app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +engine: claude +--- + +# Test Workflow + +Test app token with remote GitHub MCP server. +` + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + // Compile the workflow + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Failed to compile workflow") + + // Read the generated lock file (same name with .lock.yml extension) + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + content, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + lockContent := string(content) + + // Verify token minting step is present + assert.Contains(t, lockContent, "Generate GitHub App token", "Token minting step should be present") + assert.Contains(t, lockContent, "id: github-mcp-app-token", "Should use github-mcp-app-token as step ID") + + // Verify the app token is used in the authorization header for remote mode + // The token should be in the HTTP config's Authorization header + if strings.Contains(lockContent, `"Authorization": "Bearer ${{ steps.github-mcp-app-token.outputs.token }}"`) { + // Success - app token is used + t.Log("App token correctly used in remote mode Authorization header") + } else { + // Also check for the env var reference pattern used by Claude engine + assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }}", "Should use app token for GitHub MCP server in remote mode") + } +} diff --git a/pkg/workflow/mcp_environment.go b/pkg/workflow/mcp_environment.go index 490bf387b6..f92c1948a6 100644 --- a/pkg/workflow/mcp_environment.go +++ b/pkg/workflow/mcp_environment.go @@ -21,9 +21,23 @@ func collectMCPEnvironmentVariables(tools map[string]any, mcpTools []string, wor } if hasGitHub { githubTool := tools["github"] - customGitHubToken := getGitHubToken(githubTool) - effectiveToken := getEffectiveGitHubToken(customGitHubToken, workflowData.GitHubToken) - envVars["GITHUB_MCP_SERVER_TOKEN"] = effectiveToken + + // Check if GitHub App is configured for token minting + hasGitHubApp := false + if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.App != nil { + hasGitHubApp = true + } + + // If GitHub App is configured, use the app token (overrides other tokens) + if hasGitHubApp { + mcpEnvironmentLog.Print("Using GitHub App token for GitHub MCP server (overrides custom and default tokens)") + envVars["GITHUB_MCP_SERVER_TOKEN"] = "${{ steps.github-mcp-app-token.outputs.token }}" + } else { + // Otherwise, use custom token or default fallback + customGitHubToken := getGitHubToken(githubTool) + effectiveToken := getEffectiveGitHubToken(customGitHubToken, workflowData.GitHubToken) + envVars["GITHUB_MCP_SERVER_TOKEN"] = effectiveToken + } // Add lockdown value if it's determined from step output // Security: Pass step output through environment variable to prevent template injection diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 353a8fca0b..017c927092 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -237,3 +237,57 @@ func (c *Compiler) generateGitHubMCPLockdownDetectionStep(yaml *strings.Builder, yaml.WriteString(" const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs');\n") yaml.WriteString(" await determineAutomaticLockdown(github, context, core);\n") } + +// generateGitHubMCPAppTokenMintingStep generates a step to mint a GitHub App token for GitHub MCP server +// This step is added when: +// - GitHub tool is enabled with app configuration +// The step mints an installation access token with permissions matching the agent job permissions +func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, data *WorkflowData) { + // Check if GitHub tool has app configuration + if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.App == nil { + return + } + + app := data.ParsedTools.GitHub.App + githubConfigLog.Printf("Generating GitHub App token minting step for GitHub MCP server: app-id=%s", app.AppID) + + // Get permissions from the agent job - parse from YAML string + var permissions *Permissions + if data.Permissions != "" { + parser := NewPermissionsParser(data.Permissions) + permissions = parser.ToPermissions() + } else { + githubConfigLog.Print("No permissions specified, using empty permissions") + permissions = NewPermissions() + } + + // Generate the token minting step using the existing helper from safe_outputs_app.go + steps := c.buildGitHubAppTokenMintStep(app, permissions) + + // Modify the step ID to differentiate from safe-outputs app token + // Replace "safe-outputs-app-token" with "github-mcp-app-token" + for _, step := range steps { + modifiedStep := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: github-mcp-app-token") + yaml.WriteString(modifiedStep) + } +} + +// generateGitHubMCPAppTokenInvalidationStep generates a step to invalidate the GitHub App token for GitHub MCP server +// This step always runs (even on failure) to ensure tokens are properly cleaned up +func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Builder, data *WorkflowData) { + // Check if GitHub tool has app configuration + if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.App == nil { + return + } + + githubConfigLog.Print("Generating GitHub App token invalidation step for GitHub MCP server") + + // Generate the token invalidation step using the existing helper from safe_outputs_app.go + steps := c.buildGitHubAppTokenInvalidationStep() + + // Modify the step references to use github-mcp-app-token instead of safe-outputs-app-token + for _, step := range steps { + modifiedStep := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "steps.github-mcp-app-token.outputs.token") + yaml.WriteString(modifiedStep) + } +} diff --git a/pkg/workflow/safe_outputs_app.go b/pkg/workflow/safe_outputs_app.go index 5746f1b814..8e84a273e6 100644 --- a/pkg/workflow/safe_outputs_app.go +++ b/pkg/workflow/safe_outputs_app.go @@ -125,7 +125,7 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions var steps []string steps = append(steps, " - name: Generate GitHub App token\n") - steps = append(steps, " id: app-token\n") + steps = append(steps, " id: safe-outputs-app-token\n") steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/create-github-app-token"))) steps = append(steps, " with:\n") steps = append(steps, fmt.Sprintf(" app-id: %s\n", app.AppID)) @@ -238,9 +238,9 @@ func (c *Compiler) buildGitHubAppTokenInvalidationStep() []string { var steps []string steps = append(steps, " - name: Invalidate GitHub App token\n") - steps = append(steps, " if: always() && steps.app-token.outputs.token != ''\n") + steps = append(steps, " if: always() && steps.safe-outputs-app-token.outputs.token != ''\n") steps = append(steps, " env:\n") - steps = append(steps, " TOKEN: ${{ steps.app-token.outputs.token }}\n") + steps = append(steps, " TOKEN: ${{ steps.safe-outputs-app-token.outputs.token }}\n") steps = append(steps, " run: |\n") steps = append(steps, " echo \"Revoking GitHub App installation token...\"\n") steps = append(steps, " # GitHub CLI will auth with the token being revoked.\n") diff --git a/pkg/workflow/safe_outputs_app_test.go b/pkg/workflow/safe_outputs_app_test.go index c195319a14..c97e557028 100644 --- a/pkg/workflow/safe_outputs_app_test.go +++ b/pkg/workflow/safe_outputs_app_test.go @@ -132,7 +132,7 @@ Test workflow with app token minting. assert.Contains(t, stepsStr, "/installation/token", "Should call token invalidation endpoint") // Verify token is used in github-script step - assert.Contains(t, stepsStr, "${{ steps.app-token.outputs.token }}", "Should use app token in github-script") + assert.Contains(t, stepsStr, "${{ steps.safe-outputs-app-token.outputs.token }}", "Should use app token in github-script") } // TestSafeOutputsAppTokenMintingStepWithRepositories tests token minting with repositories diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index 6d362031a7..976a1a2111 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -251,7 +251,7 @@ func (c *Compiler) addSafeOutputGitHubTokenForConfig(steps *[]string, data *Work // If app is configured, use app token if data.SafeOutputs != nil && data.SafeOutputs.App != nil { - *steps = append(*steps, " github-token: ${{ steps.app-token.outputs.token }}\n") + *steps = append(*steps, " github-token: ${{ steps.safe-outputs-app-token.outputs.token }}\n") return } @@ -270,7 +270,7 @@ func (c *Compiler) addSafeOutputCopilotGitHubTokenForConfig(steps *[]string, dat // If app is configured, use app token if data.SafeOutputs != nil && data.SafeOutputs.App != nil { - *steps = append(*steps, " github-token: ${{ steps.app-token.outputs.token }}\n") + *steps = append(*steps, " github-token: ${{ steps.safe-outputs-app-token.outputs.token }}\n") return } @@ -285,7 +285,7 @@ func (c *Compiler) addSafeOutputCopilotGitHubTokenForConfig(steps *[]string, dat func (c *Compiler) addSafeOutputAgentGitHubTokenForConfig(steps *[]string, data *WorkflowData, configToken string) { // If app is configured, use app token if data.SafeOutputs != nil && data.SafeOutputs.App != nil { - *steps = append(*steps, " github-token: ${{ steps.app-token.outputs.token }}\n") + *steps = append(*steps, " github-token: ${{ steps.safe-outputs-app-token.outputs.token }}\n") return } diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index 39c76c7f59..a39884f379 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -224,6 +224,11 @@ func parseGitHubTool(val any) *GitHubToolConfig { config.Lockdown = lockdown } + // Parse app configuration for GitHub App token minting + if app, ok := configMap["app"].(map[string]any); ok { + config.App = parseAppConfig(app) + } + return config } diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index 0d11c9cb04..2465389080 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -268,6 +268,7 @@ type GitHubToolConfig struct { GitHubToken string `yaml:"github-token,omitempty"` Toolset GitHubToolsets `yaml:"toolsets,omitempty"` Lockdown bool `yaml:"lockdown,omitempty"` + App *GitHubAppConfig `yaml:"app,omitempty"` // GitHub App configuration for token minting } // PlaywrightDomain represents a domain name allowed for Playwright browser automation