Add workload identity federation support to base-action (#1378)

* 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.

* 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.
This commit is contained in:
Ashwin Bhat 2026-06-02 11:52:23 -07:00 committed by GitHub
parent 7f37f2e373
commit 420335da51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 250 additions and 126 deletions

View File

@ -11,6 +11,9 @@ on:
permissions: permissions:
contents: read 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: jobs:
ci: ci:
@ -18,20 +21,15 @@ jobs:
test-base-action: test-base-action:
uses: ./.github/workflows/test-base-action.yml uses: ./.github/workflows/test-base-action.yml
secrets: inherit # Required for ANTHROPIC_API_KEY
test-custom-executables: test-custom-executables:
uses: ./.github/workflows/test-custom-executables.yml uses: ./.github/workflows/test-custom-executables.yml
secrets: inherit
test-mcp-servers: test-mcp-servers:
uses: ./.github/workflows/test-mcp-servers.yml uses: ./.github/workflows/test-mcp-servers.yml
secrets: inherit
test-settings: test-settings:
uses: ./.github/workflows/test-settings.yml uses: ./.github/workflows/test-settings.yml
secrets: inherit
test-structured-output: test-structured-output:
uses: ./.github/workflows/test-structured-output.yml uses: ./.github/workflows/test-structured-output.yml
secrets: inherit

View File

@ -10,6 +10,13 @@ on:
default: "List the files in the current directory starting with 'package'" default: "List the files in the current directory starting with 'package'"
workflow_call: 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: jobs:
test-inline-prompt: test-inline-prompt:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -21,7 +28,9 @@ jobs:
uses: ./base-action uses: ./base-action
with: with:
prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }} 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" allowed_tools: "LS,Read"
- name: Verify inline prompt output - name: Verify inline prompt output
@ -78,7 +87,9 @@ jobs:
uses: ./base-action uses: ./base-action
with: with:
prompt_file: "test-prompt.txt" 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" allowed_tools: "LS,Read"
- name: Verify prompt file output - name: Verify prompt file output

View File

@ -5,6 +5,13 @@ on:
workflow_dispatch: workflow_dispatch:
workflow_call: 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: jobs:
test-custom-executables: test-custom-executables:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -47,7 +54,9 @@ jobs:
with: with:
prompt: | prompt: |
List the files in the current directory starting with "package" 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_claude_code_executable: /home/runner/.local/bin/claude
path_to_bun_executable: /home/runner/.bun/bin/bun path_to_bun_executable: /home/runner/.bun/bin/bun
allowed_tools: "LS,Read" allowed_tools: "LS,Read"

View File

@ -5,6 +5,13 @@ on:
workflow_dispatch: workflow_dispatch:
workflow_call: 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: jobs:
test-mcp-integration: test-mcp-integration:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -25,8 +32,11 @@ jobs:
uses: ./base-action uses: ./base-action
id: claude-test id: claude-test
with: with:
prompt: "List all available tools" prompt: "Call the test_tool tool and report its response."
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 mcp__test-server__test_tool
env: env:
# Change to test directory so it finds .mcp.json # Change to test directory so it finds .mcp.json
CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test
@ -50,21 +60,29 @@ jobs:
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" > /dev/null; then if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" > /dev/null; then
echo "✓ Found mcp_servers in output" echo "✓ Found mcp_servers in output"
# Check if test-server is connected # MCP servers can connect asynchronously, so the init event may
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers[] | select(.name == "test-server" and .status == "connected")' "$OUTPUT_FILE" > /dev/null; then # report the server as pending — check registration there, then
echo "✓ test-server is connected" # 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 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" jq '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE"
exit 1 exit 1
fi fi
# Check if mcp tools are available if jq -e '.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "mcp__test-server__test_tool")' "$OUTPUT_FILE" > /dev/null; then
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 was called"
echo "✓ MCP test tool found"
else else
echo "✗ MCP test tool not found" echo "✗ MCP test tool was not called"
jq '.[] | select(.type == "system" and .subtype == "init") | .tools' "$OUTPUT_FILE" 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 response not found"
exit 1 exit 1
fi fi
else else
@ -106,9 +124,13 @@ jobs:
uses: ./base-action uses: ./base-action
id: claude-config-test id: claude-config-test
with: with:
prompt: "List all available tools" prompt: "Call the test_tool tool and report its response."
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }}
mcp_config: '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}' anthropic_organization_id: ${{ vars.ANTHROPIC_ORGANIZATION_ID }}
anthropic_service_account_id: ${{ vars.ANTHROPIC_SERVICE_ACCOUNT_ID }}
claude_args: |
--allowedTools mcp__test-server__test_tool
--mcp-config '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}'
env: env:
# Change to test directory so bun can find the MCP server script # Change to test directory so bun can find the MCP server script
CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test CLAUDE_WORKING_DIR: ${{ github.workspace }}/base-action/test/mcp-test
@ -132,21 +154,29 @@ jobs:
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" > /dev/null; then if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE" > /dev/null; then
echo "✓ Found mcp_servers in output" echo "✓ Found mcp_servers in output"
# Check if test-server is connected # MCP servers can connect asynchronously, so the init event may
if jq -e '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers[] | select(.name == "test-server" and .status == "connected")' "$OUTPUT_FILE" > /dev/null; then # report the server as pending — check registration there, then
echo "✓ test-server is connected" # 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 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" jq '.[] | select(.type == "system" and .subtype == "init") | .mcp_servers' "$OUTPUT_FILE"
exit 1 exit 1
fi fi
# Check if mcp tools are available if jq -e '.[] | select(.type == "assistant") | .message.content[]? | select(.type == "tool_use" and .name == "mcp__test-server__test_tool")' "$OUTPUT_FILE" > /dev/null; then
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 was called"
echo "✓ MCP test tool found"
else else
echo "✗ MCP test tool not found" echo "✗ MCP test tool was not called"
jq '.[] | select(.type == "system" and .subtype == "init") | .tools' "$OUTPUT_FILE" 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 response not found"
exit 1 exit 1
fi fi
else else

View File

@ -5,6 +5,13 @@ on:
workflow_dispatch: workflow_dispatch:
workflow_call: 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: jobs:
test-settings-inline-allow: test-settings-inline-allow:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -17,7 +24,9 @@ jobs:
with: with:
prompt: | prompt: |
Use Bash to echo "Hello from settings test" 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: | settings: |
{ {
"permissions": { "permissions": {
@ -66,7 +75,9 @@ jobs:
with: with:
prompt: | prompt: |
Run the command `echo $HOME` to check the home directory path 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: | settings: |
{ {
"permissions": { "permissions": {
@ -108,7 +119,9 @@ jobs:
with: with:
prompt: | prompt: |
Use Bash to echo "Hello from settings file test" 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" settings: "test-settings.json"
- name: Verify echo worked - name: Verify echo worked
@ -162,7 +175,9 @@ jobs:
with: with:
prompt: | prompt: |
Run the command `echo $HOME` to check the home directory path 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" settings: "test-settings.json"
- name: Verify echo was denied - name: Verify echo was denied

View File

@ -5,8 +5,12 @@ on:
workflow_dispatch: workflow_dispatch:
workflow_call: 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: permissions:
contents: read contents: read
id-token: write
jobs: jobs:
test-basic-types: test-basic-types:
@ -28,7 +32,9 @@ jobs:
- number_field: 42 - number_field: 42
- boolean_true: true - boolean_true: true
- boolean_false: false - 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: | claude_args: |
--allowedTools Bash --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"]}' --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"] - items: ["apple", "banana", "cherry"]
- config: {"key": "value", "count": 3} - config: {"key": "value", "count": 3}
- empty_array: [] - 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: | claude_args: |
--allowedTools Bash --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"]}' --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: "" - empty_string: ""
- negative: -5 - negative: -5
- decimal: 3.14 - 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: | claude_args: |
--allowedTools Bash --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"]}' --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: | prompt: |
Run: echo "test" Run: echo "test"
Return EXACTLY: {test-result: "passed", item_count: 10} 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: | claude_args: |
--allowedTools Bash --allowedTools Bash
--json-schema '{"type":"object","properties":{"test-result":{"type":"string"},"item_count":{"type":"number"}},"required":["test-result","item_count"]}' --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 uses: ./base-action
with: with:
prompt: "Run: echo 'complete'. Return: {done: true}" 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: | claude_args: |
--allowedTools Bash --allowedTools Bash
--json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}' --json-schema '{"type":"object","properties":{"done":{"type":"boolean"}},"required":["done"]}'

View File

@ -93,45 +93,53 @@ Add the following to your workflow file:
### Workload Identity Federation ### 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 ```yaml
- name: Run Claude Code with workload identity federation permissions:
uses: anthropics/claude-code-base-action@beta contents: read
with: id-token: write
prompt: "Your prompt here"
env: steps:
ANTHROPIC_FEDERATION_RULE_ID: fdrl_xxxxxxxxxxxx - name: Run Claude Code with workload identity federation
ANTHROPIC_ORGANIZATION_ID: 00000000-0000-0000-0000-000000000000 uses: anthropics/claude-code-base-action@beta
ANTHROPIC_SERVICE_ACCOUNT_ID: svac_xxxxxxxxxxxx with:
ANTHROPIC_IDENTITY_TOKEN_FILE: /path/to/identity-token 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 ## Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
| `prompt` | The prompt to send to Claude Code | No\* | '' | | `prompt` | The prompt to send to Claude Code | No\* | '' |
| `prompt_file` | Path to a file containing 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 | '' | | `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 | '' | | `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 | '' | | `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 | '' | | `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 | '' | | `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
| `system_prompt` | Override system prompt | No | '' | | `system_prompt` | Override system prompt | No | '' |
| `append_system_prompt` | Append to 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 | '' | | `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' | | `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' | | `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 | '' | | `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 | '' | | `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 | '' | | `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' | | `anthropic_federation_rule_id` | Workload identity federation rule ID (fdrl\_...). Requires `id-token: write` permission | No | '' |
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | 'false' | | `anthropic_organization_id` | Anthropic organization UUID used for workload identity federation | No | '' |
| `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' | | `anthropic_service_account_id` | Service account ID (svac\_...) the federated token acts as (optional) | No | '' |
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#-full-output-security-warning)) | No | 'false'\*\* | | `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. \*Either `prompt` or `prompt_file` must be provided, but not both.

View File

@ -34,6 +34,26 @@ inputs:
description: "Claude Code OAuth token (alternative to anthropic_api_key)" description: "Claude Code OAuth token (alternative to anthropic_api_key)"
required: false required: false
default: "" 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: use_bedrock:
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API" description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
required: false required: false
@ -175,6 +195,11 @@ runs:
# Provider configuration # Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }} 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_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }} ANTHROPIC_CUSTOM_HEADERS: ${{ env.ANTHROPIC_CUSTOM_HEADERS }}
# Only set provider flags if explicitly true, since any value (including "false") is truthy # Only set provider flags if explicitly true, since any value (including "false") is truthy

View File

@ -7,9 +7,16 @@ import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { validateEnvironmentVariables } from "./validate-env"; import { validateEnvironmentVariables } from "./validate-env";
import { installPlugins } from "./install-plugins"; import { installPlugins } from "./install-plugins";
import { setExecutionFileOutputIfPresent } from "./execution-file"; import { setExecutionFileOutputIfPresent } from "./execution-file";
import { setupWorkloadIdentity } from "./workload-identity";
import type { WorkloadIdentityHandle } from "./workload-identity";
async function run() { async function run() {
let workloadIdentity: WorkloadIdentityHandle | undefined;
try { 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(); validateEnvironmentVariables();
// The composite action's "Install Claude Code" step writes the binary to // 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.setFailed(`Action failed with error: ${error}`);
core.setOutput("conclusion", "failure"); core.setOutput("conclusion", "failure");
process.exit(1); process.exit(1);
} finally {
// Stop refreshing the workload identity token file so the process can exit
workloadIdentity?.stop();
} }
} }

47
base-action/src/retry.ts Normal file
View File

@ -0,0 +1,47 @@
export type RetryOptions = {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffFactor?: number;
shouldRetry?: (error: Error) => boolean;
};
export async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
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;
}

View File

@ -17,7 +17,7 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { mkdirSync, writeFileSync } from "fs"; import { mkdirSync, writeFileSync } from "fs";
import { join } from "path"; import { join } from "path";
import { retryWithBackoff } from "../utils/retry"; import { retryWithBackoff } from "./retry";
/** How often the GitHub OIDC identity token file is rewritten. */ /** How often the GitHub OIDC identity token file is rewritten. */
const REFRESH_INTERVAL_MS = 4 * 60 * 1000; const REFRESH_INTERVAL_MS = 4 * 60 * 1000;

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test";
import { retryWithBackoff } from "../src/utils/retry"; import { retryWithBackoff } from "../src/retry";
describe("retryWithBackoff", () => { describe("retryWithBackoff", () => {
let originalConsoleLog: typeof console.log; let originalConsoleLog: typeof console.log;

View File

@ -8,7 +8,7 @@ import { join } from "path";
import { import {
isWorkloadIdentityConfigured, isWorkloadIdentityConfigured,
setupWorkloadIdentity, setupWorkloadIdentity,
} from "../src/auth/workload-identity"; } from "../src/workload-identity";
describe("workload identity federation", () => { describe("workload identity federation", () => {
let originalEnv: NodeJS.ProcessEnv; let originalEnv: NodeJS.ProcessEnv;

View File

@ -29,13 +29,13 @@ import { prepareAgentMode } from "../modes/agent";
import { checkContainsTrigger } from "../github/validation/trigger"; import { checkContainsTrigger } from "../github/validation/trigger";
import { restoreConfigFromBase } from "../github/operations/restore-config"; import { restoreConfigFromBase } from "../github/operations/restore-config";
import { validateBranchName } from "../github/operations/branch"; 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 { collectActionInputsPresence } from "./collect-inputs";
import { updateCommentLink } from "./update-comment-link"; import { updateCommentLink } from "./update-comment-link";
import { formatTurnsFromData } from "./format-turns"; import { formatTurnsFromData } from "./format-turns";
import type { Turn } from "./format-turns"; import type { Turn } from "./format-turns";
// Base-action imports (used directly instead of subprocess) // 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 { validateEnvironmentVariables } from "../../base-action/src/validate-env";
import { setupClaudeCodeSettings } from "../../base-action/src/setup-claude-code-settings"; import { setupClaudeCodeSettings } from "../../base-action/src/setup-claude-code-settings";
import { installPlugins } from "../../base-action/src/install-plugins"; import { installPlugins } from "../../base-action/src/install-plugins";

View File

@ -1,47 +1,4 @@
export type RetryOptions = { export {
maxAttempts?: number; retryWithBackoff,
initialDelayMs?: number; type RetryOptions,
maxDelayMs?: number; } from "../../base-action/src/retry";
backoffFactor?: number;
shouldRetry?: (error: Error) => boolean;
};
export async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
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;
}