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:
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

View File

@ -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

View File

@ -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"

View File

@ -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
@ -25,8 +32,11 @@ jobs:
uses: ./base-action
id: claude-test
with:
prompt: "List all available tools"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
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
@ -50,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 not found"
jq '.[] | select(.type == "system" and .subtype == "init") | .tools' "$OUTPUT_FILE"
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 response not found"
exit 1
fi
else
@ -106,9 +124,13 @@ jobs:
uses: ./base-action
id: claude-config-test
with:
prompt: "List all available tools"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
mcp_config: '{"mcpServers":{"test-server":{"type":"stdio","command":"bun","args":["simple-mcp-server.ts"],"env":{}}}}'
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
--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
@ -132,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 not found"
jq '.[] | select(.type == "system" and .subtype == "init") | .tools' "$OUTPUT_FILE"
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 response not found"
exit 1
fi
else

View File

@ -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

View File

@ -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"]}'

View File

@ -93,26 +93,29 @@ 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
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"
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
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 | '' |
@ -128,6 +131,11 @@ Note: the base action does not fetch or refresh the identity token itself — yo
| `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' |

View File

@ -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

View File

@ -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();
}
}

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 { 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;

View File

@ -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;

View File

@ -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;

View File

@ -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";

View File

@ -1,47 +1,4 @@
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;
}
export {
retryWithBackoff,
type RetryOptions,
} from "../../base-action/src/retry";