From 00c5e44c69b1f4e31a5497053479d166be844764 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Tue, 2 Jun 2026 11:41:08 -0700 Subject: [PATCH 1/2] Add workload identity federation support to base-action Move the workload identity module into base-action so the standalone action can fetch and refresh the GitHub OIDC identity token itself, and expose the same federation inputs as the outer action. Switch the base-action test workflows from the anthropic_api_key secret to the federation repo variables and grant them id-token: write. --- .github/workflows/ci-all.yml | 8 +-- .github/workflows/test-base-action.yml | 15 +++- .github/workflows/test-custom-executables.yml | 11 ++- .github/workflows/test-mcp-servers.yml | 15 +++- .github/workflows/test-settings.yml | 23 ++++-- .github/workflows/test-structured-output.yml | 24 +++++-- base-action/README.md | 72 ++++++++++--------- base-action/action.yml | 25 +++++++ base-action/src/index.ts | 10 +++ base-action/src/retry.ts | 47 ++++++++++++ .../src}/workload-identity.ts | 2 +- {test => base-action/test}/retry.test.ts | 2 +- .../test}/workload-identity.test.ts | 2 +- src/entrypoints/run.ts | 4 +- src/utils/retry.ts | 51 ++----------- 15 files changed, 208 insertions(+), 103 deletions(-) create mode 100644 base-action/src/retry.ts rename {src/auth => base-action/src}/workload-identity.ts (98%) rename {test => base-action/test}/retry.test.ts (98%) rename {test => base-action/test}/workload-identity.test.ts (99%) diff --git a/.github/workflows/ci-all.yml b/.github/workflows/ci-all.yml index 2c29d9ff6..e914fed56 100644 --- a/.github/workflows/ci-all.yml +++ b/.github/workflows/ci-all.yml @@ -11,6 +11,9 @@ on: permissions: contents: read + # Lets the test workflows mint the GitHub OIDC token they exchange for a + # Claude API access token (workload identity federation). See docs/setup.md. + id-token: write jobs: ci: @@ -18,20 +21,15 @@ jobs: test-base-action: uses: ./.github/workflows/test-base-action.yml - secrets: inherit # Required for ANTHROPIC_API_KEY test-custom-executables: uses: ./.github/workflows/test-custom-executables.yml - secrets: inherit test-mcp-servers: uses: ./.github/workflows/test-mcp-servers.yml - secrets: inherit test-settings: uses: ./.github/workflows/test-settings.yml - secrets: inherit test-structured-output: uses: ./.github/workflows/test-structured-output.yml - secrets: inherit diff --git a/.github/workflows/test-base-action.yml b/.github/workflows/test-base-action.yml index 42474fbbb..f20f8608e 100644 --- a/.github/workflows/test-base-action.yml +++ b/.github/workflows/test-base-action.yml @@ -10,6 +10,13 @@ on: default: "List the files in the current directory starting with 'package'" workflow_call: +# The Claude API is authenticated via workload identity federation: id-token +# lets the action mint the GitHub OIDC token it exchanges for a short-lived +# access token. See docs/setup.md. +permissions: + contents: read + id-token: write + jobs: test-inline-prompt: runs-on: ubuntu-latest @@ -21,7 +28,9 @@ jobs: uses: ./base-action with: prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }} - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} allowed_tools: "LS,Read" - name: Verify inline prompt output @@ -78,7 +87,9 @@ jobs: uses: ./base-action with: prompt_file: "test-prompt.txt" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} allowed_tools: "LS,Read" - name: Verify prompt file output diff --git a/.github/workflows/test-custom-executables.yml b/.github/workflows/test-custom-executables.yml index 59c591bf2..6b6609c2f 100644 --- a/.github/workflows/test-custom-executables.yml +++ b/.github/workflows/test-custom-executables.yml @@ -5,6 +5,13 @@ on: workflow_dispatch: workflow_call: +# The Claude API is authenticated via workload identity federation: id-token +# lets the action mint the GitHub OIDC token it exchanges for a short-lived +# access token. See docs/setup.md. +permissions: + contents: read + id-token: write + jobs: test-custom-executables: runs-on: ubuntu-latest @@ -47,7 +54,9 @@ jobs: with: prompt: | List the files in the current directory starting with "package" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} path_to_claude_code_executable: /home/runner/.local/bin/claude path_to_bun_executable: /home/runner/.bun/bin/bun allowed_tools: "LS,Read" diff --git a/.github/workflows/test-mcp-servers.yml b/.github/workflows/test-mcp-servers.yml index a3182b41d..c25609869 100644 --- a/.github/workflows/test-mcp-servers.yml +++ b/.github/workflows/test-mcp-servers.yml @@ -5,6 +5,13 @@ on: workflow_dispatch: workflow_call: +# The Claude API is authenticated via workload identity federation: id-token +# lets the action mint the GitHub OIDC token it exchanges for a short-lived +# access token. See docs/setup.md. +permissions: + contents: read + id-token: write + jobs: test-mcp-integration: runs-on: ubuntu-latest @@ -26,7 +33,9 @@ jobs: id: claude-test with: prompt: "List all available tools" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} env: # Change to test directory so it finds .mcp.json CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test @@ -107,7 +116,9 @@ jobs: id: claude-config-test with: prompt: "List all available tools" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} mcp_config: '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}' env: # Change to test directory so bun can find the MCP server script diff --git a/.github/workflows/test-settings.yml b/.github/workflows/test-settings.yml index b04368039..0eb55b400 100644 --- a/.github/workflows/test-settings.yml +++ b/.github/workflows/test-settings.yml @@ -5,6 +5,13 @@ on: workflow_dispatch: workflow_call: +# The Claude API is authenticated via workload identity federation: id-token +# lets the action mint the GitHub OIDC token it exchanges for a short-lived +# access token. See docs/setup.md. +permissions: + contents: read + id-token: write + jobs: test-settings-inline-allow: runs-on: ubuntu-latest @@ -17,7 +24,9 @@ jobs: with: prompt: | Use Bash to echo "Hello from settings test" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} settings: | { "permissions": { @@ -66,7 +75,9 @@ jobs: with: prompt: | Run the command `echo $HOME` to check the home directory path - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} settings: | { "permissions": { @@ -108,7 +119,9 @@ jobs: with: prompt: | Use Bash to echo "Hello from settings file test" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} settings: "test-settings.json" - name: Verify echo worked @@ -162,7 +175,9 @@ jobs: with: prompt: | Run the command `echo $HOME` to check the home directory path - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} settings: "test-settings.json" - name: Verify echo was denied diff --git a/.github/workflows/test-structured-output.yml b/.github/workflows/test-structured-output.yml index adc9213ec..43979cd87 100644 --- a/.github/workflows/test-structured-output.yml +++ b/.github/workflows/test-structured-output.yml @@ -5,8 +5,12 @@ on: workflow_dispatch: workflow_call: +# The Claude API is authenticated via workload identity federation: id-token +# lets the action mint the GitHub OIDC token it exchanges for a short-lived +# access token. See docs/setup.md. permissions: contents: read + id-token: write jobs: test-basic-types: @@ -28,7 +32,9 @@ jobs: - number_field: 42 - boolean_true: true - boolean_false: false - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} claude_args: | --allowedTools Bash --json-schema '{"type":"object","properties":{"text_field":{"type":"string"},"number_field":{"type":"number"},"boolean_true":{"type":"boolean"},"boolean_false":{"type":"boolean"}},"required":["text_field","number_field","boolean_true","boolean_false"]}' @@ -86,7 +92,9 @@ jobs: - items: ["apple", "banana", "cherry"] - config: {"key": "value", "count": 3} - empty_array: [] - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} claude_args: | --allowedTools Bash --json-schema '{"type":"object","properties":{"items":{"type":"array","items":{"type":"string"}},"config":{"type":"object"},"empty_array":{"type":"array"}},"required":["items","config","empty_array"]}' @@ -138,7 +146,9 @@ jobs: - empty_string: "" - negative: -5 - decimal: 3.14 - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} claude_args: | --allowedTools Bash --json-schema '{"type":"object","properties":{"zero":{"type":"number"},"empty_string":{"type":"string"},"negative":{"type":"number"},"decimal":{"type":"number"}},"required":["zero","empty_string","negative","decimal"]}' @@ -192,7 +202,9 @@ jobs: prompt: | Run: echo "test" Return EXACTLY: {test-result: "passed", item_count: 10} - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} claude_args: | --allowedTools Bash --json-schema '{"type":"object","properties":{"test-result":{"type":"string"},"item_count":{"type":"number"}},"required":["test-result","item_count"]}' @@ -230,7 +242,9 @@ jobs: uses: ./base-action with: prompt: "Run: echo 'complete'. Return: {done: true}" - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} + anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} claude_args: | --allowedTools Bash --json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}' diff --git a/base-action/README.md b/base-action/README.md index 792c19ac2..d66544062 100644 --- a/base-action/README.md +++ b/base-action/README.md @@ -93,45 +93,53 @@ Add the following to your workflow file: ### Workload Identity Federation -Instead of a static API key or OAuth token, you can authenticate via [Workload Identity Federation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation) by setting the federation environment variables on the step. Fetch an OIDC identity token from your provider, write it to a file, and point the action at it: +Instead of a static API key or OAuth token, you can authenticate via [Workload Identity Federation](https://platform.claude.com/docs/en/manage-claude/workload-identity-federation): the action fetches the workflow's GitHub OIDC token and the Claude Code CLI exchanges it for a short-lived access token. Requires the `id-token: write` permission on the job: ```yaml -- name: Run Claude Code with workload identity federation - uses: anthropics/claude-code-base-action@beta - with: - prompt: "Your prompt here" - env: - ANTHROPIC_FEDERATION_RULE_ID: fdrl_xxxxxxxxxxxx - ANTHROPIC_ORGANIZATION_ID: 00000000-0000-0000-0000-000000000000 - ANTHROPIC_SERVICE_ACCOUNT_ID: svac_xxxxxxxxxxxx - ANTHROPIC_IDENTITY_TOKEN_FILE: /path/to/identity-token +permissions: + contents: read + id-token: write + +steps: + - name: Run Claude Code with workload identity federation + uses: anthropics/claude-code-base-action@beta + with: + prompt: "Your prompt here" + anthropic_federation_rule_id: fdrl_xxxxxxxxxxxx + anthropic_organization_id: 00000000-0000-0000-0000-000000000000 + anthropic_service_account_id: svac_xxxxxxxxxxxx ``` -Note: the base action does not fetch or refresh the identity token itself — you are responsible for providing a valid token file. [`anthropics/claude-code-action`](https://github.com/anthropics/claude-code-action) handles fetching and refreshing the GitHub Actions OIDC token automatically via its `anthropic_federation_rule_id` input. +Do not set `anthropic_api_key` or `claude_code_oauth_token` alongside the federation inputs — a static credential takes precedence and federation will not be used. ## Inputs -| Input | Description | Required | Default | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | -| `prompt` | The prompt to send to Claude Code | No\* | '' | -| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' | -| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' | -| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' | -| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' | -| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' | -| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' | -| `system_prompt` | Override system prompt | No | '' | -| `append_system_prompt` | Append to system prompt | No | '' | -| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' | -| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' | -| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' | -| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' | -| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' | -| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' | -| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' | -| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' | -| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' | -| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#️-full-output-security-warning)) | No | 'false'\*\* | +| Input | Description | Required | Default | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | +| `prompt` | The prompt to send to Claude Code | No\* | '' | +| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' | +| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' | +| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' | +| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' | +| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' | +| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' | +| `system_prompt` | Override system prompt | No | '' | +| `append_system_prompt` | Append to system prompt | No | '' | +| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' | +| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' | +| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' | +| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' | +| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' | +| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' | +| `anthropic_federation_rule_id` | Workload identity federation rule ID (fdrl\_...). Requires `id-token: write` permission | No | '' | +| `anthropic_organization_id` | Anthropic organization UUID used for workload identity federation | No | '' | +| `anthropic_service_account_id` | Service account ID (svac\_...) the federated token acts as (optional) | No | '' | +| `anthropic_workspace_id` | Workspace ID (wrkspc\_...) for federation. Optional when the rule targets a single workspace | No | '' | +| `anthropic_oidc_audience` | Audience to request on the GitHub OIDC token. Defaults to https://api.anthropic.com | No | '' | +| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' | +| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' | +| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' | +| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#️-full-output-security-warning)) | No | 'false'\*\* | \*Either `prompt` or `prompt_file` must be provided, but not both. diff --git a/base-action/action.yml b/base-action/action.yml index 8785043ed..7849612ea 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -34,6 +34,26 @@ inputs: description: "Claude Code OAuth token (alternative to anthropic_api_key)" required: false default: "" + anthropic_federation_rule_id: + description: "Workload identity federation rule ID (fdrl_...). When set with anthropic_organization_id, the action authenticates to the Claude API by exchanging the workflow's GitHub OIDC token instead of using a static API key. Requires `id-token: write` permission." + required: false + default: "" + anthropic_organization_id: + description: "Anthropic organization UUID used for workload identity federation" + required: false + default: "" + anthropic_service_account_id: + description: "Service account ID (svac_...) the federated token acts as (optional, used with workload identity federation)" + required: false + default: "" + anthropic_workspace_id: + description: "Workspace ID (wrkspc_...) for workload identity federation. Optional when the federation rule targets a single workspace." + required: false + default: "" + anthropic_oidc_audience: + description: "Audience to request on the GitHub OIDC token used for workload identity federation. Defaults to https://api.anthropic.com." + required: false + default: "" use_bedrock: description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API" required: false @@ -175,6 +195,11 @@ runs: # Provider configuration ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }} + ANTHROPIC_FEDERATION_RULE_ID: ${{ inputs.anthropic_federation_rule_id }} + ANTHROPIC_ORGANIZATION_ID: ${{ inputs.anthropic_organization_id }} + ANTHROPIC_SERVICE_ACCOUNT_ID: ${{ inputs.anthropic_service_account_id }} + ANTHROPIC_WORKSPACE_ID: ${{ inputs.anthropic_workspace_id }} + ANTHROPIC_OIDC_AUDIENCE: ${{ inputs.anthropic_oidc_audience }} ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }} ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }} # Only set provider flags if explicitly true, since any value (including "false") is truthy diff --git a/base-action/src/index.ts b/base-action/src/index.ts index 8ec84ac1b..e95c2f64a 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -7,9 +7,16 @@ import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { validateEnvironmentVariables } from "./validate-env"; import { installPlugins } from "./install-plugins"; import { setExecutionFileOutputIfPresent } from "./execution-file"; +import { setupWorkloadIdentity } from "./workload-identity"; +import type { WorkloadIdentityHandle } from "./workload-identity"; async function run() { + let workloadIdentity: WorkloadIdentityHandle | undefined; try { + // When workload identity federation is configured, fetch the GitHub OIDC + // identity token and expose it to the CLI before validating auth env vars. + workloadIdentity = await setupWorkloadIdentity(); + validateEnvironmentVariables(); // The composite action's "Install Claude Code" step writes the binary to @@ -67,6 +74,9 @@ async function run() { core.setFailed(`Action failed with error: ${error}`); core.setOutput("conclusion", "failure"); process.exit(1); + } finally { + // Stop refreshing the workload identity token file so the process can exit + workloadIdentity?.stop(); } } diff --git a/base-action/src/retry.ts b/base-action/src/retry.ts new file mode 100644 index 000000000..37d5a72eb --- /dev/null +++ b/base-action/src/retry.ts @@ -0,0 +1,47 @@ +export type RetryOptions = { + maxAttempts?: number; + initialDelayMs?: number; + maxDelayMs?: number; + backoffFactor?: number; + shouldRetry?: (error: Error) => boolean; +}; + +export async function retryWithBackoff( + operation: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + initialDelayMs = 5000, + maxDelayMs = 20000, + backoffFactor = 2, + shouldRetry, + } = options; + + let delayMs = initialDelayMs; + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + console.log(`Attempt ${attempt} of ${maxAttempts}...`); + return await operation(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + console.error(`Attempt ${attempt} failed:`, lastError.message); + + if (shouldRetry && !shouldRetry(lastError)) { + console.error("Error is not retryable, giving up immediately"); + throw lastError; + } + + if (attempt < maxAttempts) { + console.log(`Retrying in ${delayMs / 1000} seconds...`); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * backoffFactor, maxDelayMs); + } + } + } + + console.error(`Operation failed after ${maxAttempts} attempts`); + throw lastError; +} diff --git a/src/auth/workload-identity.ts b/base-action/src/workload-identity.ts similarity index 98% rename from src/auth/workload-identity.ts rename to base-action/src/workload-identity.ts index 6634ae2c6..91266f32b 100644 --- a/src/auth/workload-identity.ts +++ b/base-action/src/workload-identity.ts @@ -17,7 +17,7 @@ import * as core from "@actions/core"; import { mkdirSync, writeFileSync } from "fs"; import { join } from "path"; -import { retryWithBackoff } from "../utils/retry"; +import { retryWithBackoff } from "./retry"; /** How often the GitHub OIDC identity token file is rewritten. */ const REFRESH_INTERVAL_MS = 4 * 60 * 1000; diff --git a/test/retry.test.ts b/base-action/test/retry.test.ts similarity index 98% rename from test/retry.test.ts rename to base-action/test/retry.test.ts index 0d5c0bbe2..af4b007be 100644 --- a/test/retry.test.ts +++ b/base-action/test/retry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; -import { retryWithBackoff } from "../src/utils/retry"; +import { retryWithBackoff } from "../src/retry"; describe("retryWithBackoff", () => { let originalConsoleLog: typeof console.log; diff --git a/test/workload-identity.test.ts b/base-action/test/workload-identity.test.ts similarity index 99% rename from test/workload-identity.test.ts rename to base-action/test/workload-identity.test.ts index 8fde17f9c..e95f4e06d 100644 --- a/test/workload-identity.test.ts +++ b/base-action/test/workload-identity.test.ts @@ -8,7 +8,7 @@ import { join } from "path"; import { isWorkloadIdentityConfigured, setupWorkloadIdentity, -} from "../src/auth/workload-identity"; +} from "../src/workload-identity"; describe("workload identity federation", () => { let originalEnv: NodeJS.ProcessEnv; diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 59e23eb26..9fe4b9774 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -29,13 +29,13 @@ import { prepareAgentMode } from "../modes/agent"; import { checkContainsTrigger } from "../github/validation/trigger"; import { restoreConfigFromBase } from "../github/operations/restore-config"; import { validateBranchName } from "../github/operations/branch"; -import { setupWorkloadIdentity } from "../auth/workload-identity"; -import type { WorkloadIdentityHandle } from "../auth/workload-identity"; import { collectActionInputsPresence } from "./collect-inputs"; import { updateCommentLink } from "./update-comment-link"; import { formatTurnsFromData } from "./format-turns"; import type { Turn } from "./format-turns"; // Base-action imports (used directly instead of subprocess) +import { setupWorkloadIdentity } from "../../base-action/src/workload-identity"; +import type { WorkloadIdentityHandle } from "../../base-action/src/workload-identity"; import { validateEnvironmentVariables } from "../../base-action/src/validate-env"; import { setupClaudeCodeSettings } from "../../base-action/src/setup-claude-code-settings"; import { installPlugins } from "../../base-action/src/install-plugins"; diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 37d5a72eb..fec74d4ff 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -1,47 +1,4 @@ -export type RetryOptions = { - maxAttempts?: number; - initialDelayMs?: number; - maxDelayMs?: number; - backoffFactor?: number; - shouldRetry?: (error: Error) => boolean; -}; - -export async function retryWithBackoff( - operation: () => Promise, - options: RetryOptions = {}, -): Promise { - const { - maxAttempts = 3, - initialDelayMs = 5000, - maxDelayMs = 20000, - backoffFactor = 2, - shouldRetry, - } = options; - - let delayMs = initialDelayMs; - let lastError: Error | undefined; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - console.log(`Attempt ${attempt} of ${maxAttempts}...`); - return await operation(); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - console.error(`Attempt ${attempt} failed:`, lastError.message); - - if (shouldRetry && !shouldRetry(lastError)) { - console.error("Error is not retryable, giving up immediately"); - throw lastError; - } - - if (attempt < maxAttempts) { - console.log(`Retrying in ${delayMs / 1000} seconds...`); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - delayMs = Math.min(delayMs * backoffFactor, maxDelayMs); - } - } - } - - console.error(`Operation failed after ${maxAttempts} attempts`); - throw lastError; -} +export { + retryWithBackoff, + type RetryOptions, +} from "../../base-action/src/retry"; From 4bf15c11c65e3c2cbc1abb8b9aa65e8964677265 Mon Sep 17 00:00:00 2001 From: Ashwin Bhat Date: Tue, 2 Jun 2026 11:48:13 -0700 Subject: [PATCH 2/2] Verify MCP test tool invocation instead of init connection status MCP servers can connect asynchronously, so the init event may report a server as pending. Check that the server is registered at init, then assert the test tool was actually called and returned its response. Also pass the MCP config through claude_args --mcp-config, replacing the removed mcp_config input. --- .github/workflows/test-mcp-servers.yml | 65 +++++++++++++++++--------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test-mcp-servers.yml b/.github/workflows/test-mcp-servers.yml index c25609869..c807b61e6 100644 --- a/.github/workflows/test-mcp-servers.yml +++ b/.github/workflows/test-mcp-servers.yml @@ -32,10 +32,11 @@ jobs: uses: ./base-action id: claude-test with: - prompt: "List all available tools" + prompt: "Call the test_tool tool and report its response." anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} + claude_args: --allowedTools mcp__test-server__test_tool env: # Change to test directory so it finds .mcp.json CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test @@ -59,21 +60,29 @@ jobs: if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" > /dev/null; then echo "✓ Found mcp_servers in output" - # Check if test-server is connected - if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers[] | select(.name == "test-server" and .status == "connected")' "$OUTPUT_FILE" > /dev/null; then - echo "✓ test-server is connected" + # MCP servers can connect asynchronously, so the init event may + # report the server as pending — check registration there, then + # verify the tool actually ran. + if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers[] | select(.name == "test-server")' "$OUTPUT_FILE" > /dev/null; then + echo "✓ test-server is registered" else - echo "✗ test-server not found or not connected" + echo "✗ test-server not found" jq '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" exit 1 fi - - # Check if mcp tools are available - if jq -e '.[] | select(.type == "system" and .subtype == "init") | .tools[] | select(. == "mcp__test-server__test_tool")' "$OUTPUT_FILE" > /dev/null; then - echo "✓ MCP test tool found" + + if jq -e '.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "mcp__test-server__test_tool")' "$OUTPUT_FILE" > /dev/null; then + echo "✓ MCP test tool was called" + else + echo "✗ MCP test tool was not called" + jq '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name]' "$OUTPUT_FILE" + exit 1 + fi + + if jq -e '.[] | select(.type == "user") | .message.content[]? | select(.type == "tool_result") | select(.content | tostring | contains("Test tool response"))' "$OUTPUT_FILE" > /dev/null; then + echo "✓ MCP test tool returned its response" else - echo "✗ MCP test tool not found" - jq '.[] | select(.type == "system" and .subtype == "init") | .tools' "$OUTPUT_FILE" + echo "✗ MCP test tool response not found" exit 1 fi else @@ -115,11 +124,13 @@ jobs: uses: ./base-action id: claude-config-test with: - prompt: "List all available tools" + prompt: "Call the test_tool tool and report its response." anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }} anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} - mcp_config: '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}' + claude_args: | + --allowedTools mcp__test-server__test_tool + --mcp-config '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}' env: # Change to test directory so bun can find the MCP server script CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test @@ -143,21 +154,29 @@ jobs: if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" > /dev/null; then echo "✓ Found mcp_servers in output" - # Check if test-server is connected - if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers[] | select(.name == "test-server" and .status == "connected")' "$OUTPUT_FILE" > /dev/null; then - echo "✓ test-server is connected" + # MCP servers can connect asynchronously, so the init event may + # report the server as pending — check registration there, then + # verify the tool actually ran. + if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers[] | select(.name == "test-server")' "$OUTPUT_FILE" > /dev/null; then + echo "✓ test-server is registered" else - echo "✗ test-server not found or not connected" + echo "✗ test-server not found" jq '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" exit 1 fi - - # Check if mcp tools are available - if jq -e '.[] | select(.type == "system" and .subtype == "init") | .tools[] | select(. == "mcp__test-server__test_tool")' "$OUTPUT_FILE" > /dev/null; then - echo "✓ MCP test tool found" + + if jq -e '.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "mcp__test-server__test_tool")' "$OUTPUT_FILE" > /dev/null; then + echo "✓ MCP test tool was called" + else + echo "✗ MCP test tool was not called" + jq '[.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use") | .name]' "$OUTPUT_FILE" + exit 1 + fi + + if jq -e '.[] | select(.type == "user") | .message.content[]? | select(.type == "tool_result") | select(.content | tostring | contains("Test tool response"))' "$OUTPUT_FILE" > /dev/null; then + echo "✓ MCP test tool returned its response" else - echo "✗ MCP test tool not found" - jq '.[] | select(.type == "system" and .subtype == "init") | .tools' "$OUTPUT_FILE" + echo "✗ MCP test tool response not found" exit 1 fi else