mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
Compare commits
11 Commits
claude/aut
...
v1.0.31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2316a9a8db | ||
|
|
49cfcf8107 | ||
|
|
e208124d29 | ||
|
|
ba60ef7ba2 | ||
|
|
f3c892ca8d | ||
|
|
6e896a06bb | ||
|
|
a017b830c0 | ||
|
|
75f52e56b2 | ||
|
|
1bbc9e7ff7 | ||
|
|
625ea1519c | ||
|
|
a9171f0ced |
@@ -17,6 +17,7 @@ TASK OVERVIEW:
|
||||
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
|
||||
|
||||
2. Next, use gh commands to get context about the issue:
|
||||
|
||||
- Use `gh issue view ${{ github.event.issue.number }}` to retrieve the current issue's details
|
||||
- Use `gh search issues` to find similar issues that might provide context for proper categorization
|
||||
- You have access to these Bash commands:
|
||||
@@ -26,6 +27,7 @@ TASK OVERVIEW:
|
||||
- Bash(gh search:\*) - to search for similar issues
|
||||
|
||||
3. Analyze the issue content, considering:
|
||||
|
||||
- The issue title and description
|
||||
- The type of issue (bug report, feature request, question, etc.)
|
||||
- Technical areas mentioned
|
||||
@@ -34,6 +36,7 @@ TASK OVERVIEW:
|
||||
- Components affected
|
||||
|
||||
4. Select appropriate labels from the available labels list provided above:
|
||||
|
||||
- Choose labels that accurately reflect the issue's nature
|
||||
- Be specific but comprehensive
|
||||
- IMPORTANT: Add a priority label (P1, P2, or P3) based on the label descriptions from gh label list
|
||||
|
||||
37
.github/workflows/ci-all.yml
vendored
Normal file
37
.github/workflows/ci-all.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Orchestrates all CI workflows - runs on PRs, pushes to main, and manual dispatch
|
||||
# Individual test workflows are called as reusable workflows
|
||||
name: CI All
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
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
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -1,9 +1,8 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
1
.github/workflows/issue-triage.yml
vendored
1
.github/workflows/issue-triage.yml
vendored
@@ -24,5 +24,4 @@ jobs:
|
||||
prompt: "/label-issue REPO: ${{ github.repository }} ISSUE_NUMBER${{ github.event.issue.number }}"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_non_write_users: "*" # Required for issue triage workflow, if users without repo write access create issues
|
||||
bypass_write_permission_check_acknowledgment: true # Required when using wildcard
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
106
.github/workflows/release.yml
vendored
106
.github/workflows/release.yml
vendored
@@ -8,10 +8,23 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_run:
|
||||
workflows: ["CI All"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
# Run if: manual dispatch OR (CI All succeeded AND commit is a version bump)
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
startsWith(github.event.workflow_run.head_commit.message, 'chore: bump Claude Code to'))
|
||||
environment: production
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -84,7 +97,8 @@ jobs:
|
||||
|
||||
update-major-tag:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
# Skip for dry runs (workflow_run events are never dry runs)
|
||||
if: github.event_name == 'workflow_run' || !inputs.dry_run
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
@@ -109,48 +123,48 @@ jobs:
|
||||
|
||||
echo "Updated $major_version tag to point to $next_version"
|
||||
|
||||
release-base-action:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout base-action repo
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: anthropics/claude-code-base-action
|
||||
token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
fetch-depth: 0
|
||||
|
||||
# - name: Create and push tag
|
||||
# run: |
|
||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
|
||||
# git config user.name "github-actions[bot]"
|
||||
# git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# # Create the version tag
|
||||
# git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
|
||||
# git push origin "$next_version"
|
||||
|
||||
# # Update the beta tag
|
||||
# git tag -fa beta -m "Update beta tag to ${next_version}"
|
||||
# git push origin beta --force
|
||||
|
||||
# - name: Create GitHub release
|
||||
# env:
|
||||
# GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
# run: |
|
||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
|
||||
# # Create the release
|
||||
# gh release create "$next_version" \
|
||||
# --repo anthropics/claude-code-base-action \
|
||||
# --title "$next_version" \
|
||||
# --notes "Release $next_version - synced from anthropics/claude-code-action" \
|
||||
# --latest=false
|
||||
|
||||
# # Update beta release to be latest
|
||||
# gh release edit beta \
|
||||
# --repo anthropics/claude-code-base-action \
|
||||
# --latest
|
||||
# release-base-action:
|
||||
# needs: create-release
|
||||
# if: ${{ !inputs.dry_run }}
|
||||
# runs-on: ubuntu-latest
|
||||
# environment: production
|
||||
# steps:
|
||||
# - name: Checkout base-action repo
|
||||
# uses: actions/checkout@v5
|
||||
# with:
|
||||
# repository: anthropics/claude-code-base-action
|
||||
# token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
# fetch-depth: 0
|
||||
#
|
||||
# - name: Create and push tag
|
||||
# run: |
|
||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
#
|
||||
# git config user.name "github-actions[bot]"
|
||||
# git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
#
|
||||
# # Create the version tag
|
||||
# git tag -a "$next_version" -m "Release $next_version - synced from claude-code-action"
|
||||
# git push origin "$next_version"
|
||||
#
|
||||
# # Update the beta tag
|
||||
# git tag -fa beta -m "Update beta tag to ${next_version}"
|
||||
# git push origin beta --force
|
||||
#
|
||||
# - name: Create GitHub release
|
||||
# env:
|
||||
# GH_TOKEN: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
# run: |
|
||||
# next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
#
|
||||
# # Create the release
|
||||
# gh release create "$next_version" \
|
||||
# --repo anthropics/claude-code-base-action \
|
||||
# --title "$next_version" \
|
||||
# --notes "Release $next_version - synced from anthropics/claude-code-action" \
|
||||
# --latest=false
|
||||
#
|
||||
# # Update beta release to be latest
|
||||
# gh release edit beta \
|
||||
# --repo anthropics/claude-code-base-action \
|
||||
# --latest
|
||||
|
||||
62
.github/workflows/test-base-action.yml
vendored
62
.github/workflows/test-base-action.yml
vendored
@@ -1,9 +1,6 @@
|
||||
name: Test Claude Code Action
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -11,6 +8,7 @@ on:
|
||||
description: "Test prompt for Claude"
|
||||
required: false
|
||||
default: "List the files in the current directory starting with 'package'"
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-inline-prompt:
|
||||
@@ -118,61 +116,3 @@ jobs:
|
||||
echo "❌ Execution log file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test-agent-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Test with Agent SDK
|
||||
id: sdk-test
|
||||
uses: ./base-action
|
||||
env:
|
||||
USE_AGENT_SDK: "true"
|
||||
with:
|
||||
prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: "LS,Read"
|
||||
|
||||
- name: Verify SDK output
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.sdk-test.outputs.execution_file }}"
|
||||
CONCLUSION="${{ steps.sdk-test.outputs.conclusion }}"
|
||||
|
||||
echo "Conclusion: $CONCLUSION"
|
||||
echo "Output file: $OUTPUT_FILE"
|
||||
|
||||
if [ "$CONCLUSION" = "success" ]; then
|
||||
echo "✅ Action completed successfully with Agent SDK"
|
||||
else
|
||||
echo "❌ Action failed with Agent SDK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$OUTPUT_FILE" ]; then
|
||||
if [ -s "$OUTPUT_FILE" ]; then
|
||||
echo "✅ Execution log file created successfully with content"
|
||||
echo "Validating JSON format:"
|
||||
if jq . "$OUTPUT_FILE" > /dev/null 2>&1; then
|
||||
echo "✅ Output is valid JSON"
|
||||
# Verify SDK output contains total_cost_usd (SDK field name)
|
||||
if jq -e '.[] | select(.type == "result") | .total_cost_usd' "$OUTPUT_FILE" > /dev/null 2>&1; then
|
||||
echo "✅ SDK output contains total_cost_usd field"
|
||||
else
|
||||
echo "❌ SDK output missing total_cost_usd field"
|
||||
exit 1
|
||||
fi
|
||||
echo "Content preview:"
|
||||
head -c 500 "$OUTPUT_FILE"
|
||||
else
|
||||
echo "❌ Output is not valid JSON"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Execution log file is empty"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Execution log file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: Test Custom Executables
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-custom-executables:
|
||||
|
||||
4
.github/workflows/test-mcp-servers.yml
vendored
4
.github/workflows/test-mcp-servers.yml
vendored
@@ -1,11 +1,9 @@
|
||||
name: Test MCP Servers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-mcp-integration:
|
||||
|
||||
4
.github/workflows/test-settings.yml
vendored
4
.github/workflows/test-settings.yml
vendored
@@ -1,11 +1,9 @@
|
||||
name: Test Settings Feature
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-settings-inline-allow:
|
||||
|
||||
4
.github/workflows/test-structured-output.yml
vendored
4
.github/workflows/test-structured-output.yml
vendored
@@ -1,11 +1,9 @@
|
||||
name: Test Structured Outputs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
11
action.yml
11
action.yml
@@ -35,10 +35,6 @@ inputs:
|
||||
description: "Comma-separated list of usernames to allow without write permissions, or '*' to allow all users. Only works when github_token input is provided. WARNING: Use with extreme caution - this bypasses security checks and should only be used for workflows with very limited permissions (e.g., issue labeling)."
|
||||
required: false
|
||||
default: ""
|
||||
bypass_write_permission_check_acknowledgment:
|
||||
description: "REQUIRED when using allowed_non_write_users='*'. Set to 'true' to explicitly acknowledge the security implications of bypassing write permission checks for all users. This flag serves as a safeguard against accidental security misconfigurations."
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
# Claude Code configuration
|
||||
prompt:
|
||||
@@ -152,9 +148,9 @@ runs:
|
||||
steps:
|
||||
- name: Install Bun
|
||||
if: inputs.path_to_bun_executable == ''
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
||||
with:
|
||||
bun-version: 1.2.11
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
@@ -190,7 +186,6 @@ runs:
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
|
||||
BYPASS_WRITE_PERMISSION_CHECK_ACKNOWLEDGMENT: ${{ inputs.bypass_write_permission_check_acknowledgment }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||
@@ -218,7 +213,7 @@ runs:
|
||||
|
||||
# Install Claude Code if no custom executable is provided
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.1.6"
|
||||
CLAUDE_CODE_VERSION="2.1.15"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
|
||||
@@ -27,7 +27,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows.
|
||||
### Key Design Patterns
|
||||
|
||||
- Uses Bun runtime for development and execution
|
||||
- Named pipes for IPC between prompt input and Claude process
|
||||
- JSON streaming output format for execution logs
|
||||
- Composite action pattern to orchestrate multiple steps
|
||||
- Provider-agnostic design supporting Anthropic API, AWS Bedrock, and Google Vertex AI
|
||||
@@ -54,7 +53,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows.
|
||||
|
||||
## Important Technical Details
|
||||
|
||||
- Uses `mkfifo` to create named pipes for prompt input
|
||||
- Outputs execution logs as JSON to `/tmp/claude-execution-output.json`
|
||||
- Timeout enforcement via `timeout` command wrapper
|
||||
- Strict TypeScript configuration with Bun-specific settings
|
||||
|
||||
@@ -57,6 +57,7 @@ Thank you for your interest in contributing to Claude Code Base Action! This doc
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
- Installs `act` if not present (requires Homebrew on macOS)
|
||||
- Runs the GitHub Action workflow locally using Docker
|
||||
- Requires your `ANTHROPIC_API_KEY` to be set
|
||||
|
||||
@@ -85,26 +85,26 @@ Add the following to your workflow file:
|
||||
|
||||
## 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' |
|
||||
| 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'\*\* |
|
||||
|
||||
\*Either `prompt` or `prompt_file` must be provided, but not both.
|
||||
@@ -490,6 +490,7 @@ This example shows how to use OIDC authentication with GCP Vertex AI:
|
||||
To securely use your Anthropic API key:
|
||||
|
||||
1. Add your API key as a repository secret:
|
||||
|
||||
- Go to your repository's Settings
|
||||
- Navigate to "Secrets and variables" → "Actions"
|
||||
- Click "New repository secret"
|
||||
|
||||
@@ -97,9 +97,9 @@ runs:
|
||||
|
||||
- name: Install Bun
|
||||
if: inputs.path_to_bun_executable == ''
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
||||
with:
|
||||
bun-version: 1.2.11
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
@@ -124,7 +124,7 @@ runs:
|
||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
run: |
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.1.6"
|
||||
CLAUDE_CODE_VERSION="2.1.15"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"name": "@anthropic-ai/claude-code-base-action",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.6",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.15",
|
||||
"shell-quote": "^1.8.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.6", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lwswHo6z/Kh9djafk2ajPju62+VqHwJ23gueG1alfaLNK4GRYHgCROfiX6/wlxAd8sRvgTo6ry1hNzkyz7bOpw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.15", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-KN3jrHR5tIcAfLbplK5xHqNyUS3XnG8DMnImGeVEv64Z8NxfxIWtJTxtuBRWjyYzo36PEhK4r2SkX97A2iG+ng=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.6",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.15",
|
||||
"shell-quote": "^1.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -36,7 +36,6 @@ async function run() {
|
||||
mcpConfig: process.env.INPUT_MCP_CONFIG,
|
||||
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
|
||||
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
||||
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
pathToClaudeCodeExecutable:
|
||||
|
||||
@@ -212,7 +212,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||
}
|
||||
// Ensure SDK path uses the same entrypoint as the CLI path
|
||||
// Set the entrypoint for Claude Code to identify this as the GitHub Action
|
||||
env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action";
|
||||
|
||||
// Build system prompt option - default to claude_code preset
|
||||
|
||||
@@ -178,6 +178,15 @@ export async function runClaudeWithSdk(
|
||||
core.warning(`Failed to write execution file: ${error}`);
|
||||
}
|
||||
|
||||
// Extract and set session_id from system.init message
|
||||
const initMessage = messages.find(
|
||||
(m) => m.type === "system" && "subtype" in m && m.subtype === "init",
|
||||
);
|
||||
if (initMessage && "session_id" in initMessage && initMessage.session_id) {
|
||||
core.setOutput("session_id", initMessage.session_id);
|
||||
core.info(`Set session_id: ${initMessage.session_id}`);
|
||||
}
|
||||
|
||||
if (!resultMessage) {
|
||||
core.setOutput("conclusion", "failure");
|
||||
core.error("No result message received from Claude");
|
||||
|
||||
@@ -1,72 +1,6 @@
|
||||
import * as core from "@actions/core";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { unlink, writeFile, stat, readFile } from "fs/promises";
|
||||
import { createWriteStream } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { parse as parseShellArgs } from "shell-quote";
|
||||
import { runClaudeWithSdk } from "./run-claude-sdk";
|
||||
import { parseSdkOptions } from "./parse-sdk-options";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
|
||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
|
||||
|
||||
/**
|
||||
* Sanitizes JSON output to remove sensitive information when full output is disabled
|
||||
* Returns a safe summary message or null if the message should be completely suppressed
|
||||
*/
|
||||
function sanitizeJsonOutput(
|
||||
jsonObj: any,
|
||||
showFullOutput: boolean,
|
||||
): string | null {
|
||||
if (showFullOutput) {
|
||||
// In full output mode, return the full JSON
|
||||
return JSON.stringify(jsonObj, null, 2);
|
||||
}
|
||||
|
||||
// In non-full-output mode, provide minimal safe output
|
||||
const type = jsonObj.type;
|
||||
const subtype = jsonObj.subtype;
|
||||
|
||||
// System initialization - safe to show
|
||||
if (type === "system" && subtype === "init") {
|
||||
return JSON.stringify(
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
message: "Claude Code initialized",
|
||||
model: jsonObj.model || "unknown",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
// Result messages - Always show the final result
|
||||
if (type === "result") {
|
||||
// These messages contain the final result and should always be visible
|
||||
return JSON.stringify(
|
||||
{
|
||||
type: "result",
|
||||
subtype: jsonObj.subtype,
|
||||
is_error: jsonObj.is_error,
|
||||
duration_ms: jsonObj.duration_ms,
|
||||
num_turns: jsonObj.num_turns,
|
||||
total_cost_usd: jsonObj.total_cost_usd,
|
||||
permission_denials: jsonObj.permission_denials,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
// For any other message types, suppress completely in non-full-output mode
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ClaudeOptions = {
|
||||
claudeArgs?: string;
|
||||
model?: string;
|
||||
@@ -77,363 +11,11 @@ export type ClaudeOptions = {
|
||||
mcpConfig?: string;
|
||||
systemPrompt?: string;
|
||||
appendSystemPrompt?: string;
|
||||
claudeEnv?: string;
|
||||
fallbackModel?: string;
|
||||
showFullOutput?: string;
|
||||
};
|
||||
|
||||
type PreparedConfig = {
|
||||
claudeArgs: string[];
|
||||
promptPath: string;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
export function prepareRunConfig(
|
||||
promptPath: string,
|
||||
options: ClaudeOptions,
|
||||
): PreparedConfig {
|
||||
// Build Claude CLI arguments:
|
||||
// 1. Prompt flag (always first)
|
||||
// 2. User's claudeArgs (full control)
|
||||
// 3. BASE_ARGS (always last, cannot be overridden)
|
||||
|
||||
const claudeArgs = ["-p"];
|
||||
|
||||
// Parse and add user's custom Claude arguments
|
||||
if (options.claudeArgs?.trim()) {
|
||||
const parsed = parseShellArgs(options.claudeArgs);
|
||||
const customArgs = parsed.filter(
|
||||
(arg): arg is string => typeof arg === "string",
|
||||
);
|
||||
claudeArgs.push(...customArgs);
|
||||
}
|
||||
|
||||
// BASE_ARGS are always appended last (cannot be overridden)
|
||||
claudeArgs.push(...BASE_ARGS);
|
||||
|
||||
const customEnv: Record<string, string> = {};
|
||||
|
||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||
}
|
||||
|
||||
return {
|
||||
claudeArgs,
|
||||
promptPath,
|
||||
env: customEnv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses session_id from execution file and sets GitHub Action output
|
||||
* Exported for testing
|
||||
*/
|
||||
export async function parseAndSetSessionId(
|
||||
executionFile: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const content = await readFile(executionFile, "utf-8");
|
||||
const messages = JSON.parse(content) as {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
}[];
|
||||
|
||||
// Find the system.init message which contains session_id
|
||||
const initMessage = messages.find(
|
||||
(m) => m.type === "system" && m.subtype === "init",
|
||||
);
|
||||
|
||||
if (initMessage?.session_id) {
|
||||
core.setOutput("session_id", initMessage.session_id);
|
||||
core.info(`Set session_id: ${initMessage.session_id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the action if session_id extraction fails
|
||||
core.warning(`Failed to extract session_id: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses structured_output from execution file and sets GitHub Action outputs
|
||||
* Only runs if --json-schema was explicitly provided in claude_args
|
||||
* Exported for testing
|
||||
*/
|
||||
export async function parseAndSetStructuredOutputs(
|
||||
executionFile: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const content = await readFile(executionFile, "utf-8");
|
||||
const messages = JSON.parse(content) as {
|
||||
type: string;
|
||||
structured_output?: Record<string, unknown>;
|
||||
}[];
|
||||
|
||||
// Search backwards - result is typically last or second-to-last message
|
||||
const result = messages.findLast(
|
||||
(m) => m.type === "result" && m.structured_output,
|
||||
);
|
||||
|
||||
if (!result?.structured_output) {
|
||||
throw new Error(
|
||||
`--json-schema was provided but Claude did not return structured_output.\n` +
|
||||
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Set the complete structured output as a single JSON string
|
||||
// This works around GitHub Actions limitation that composite actions can't have dynamic outputs
|
||||
const structuredOutputJson = JSON.stringify(result.structured_output);
|
||||
core.setOutput("structured_output", structuredOutputJson);
|
||||
core.info(
|
||||
`Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error; // Preserve original error and stack trace
|
||||
}
|
||||
throw new Error(`Failed to parse structured outputs: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
// Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI
|
||||
const useAgentSdk = process.env.USE_AGENT_SDK !== "false";
|
||||
console.log(
|
||||
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
|
||||
);
|
||||
|
||||
if (useAgentSdk) {
|
||||
const parsedOptions = parseSdkOptions(options);
|
||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
||||
}
|
||||
|
||||
const config = prepareRunConfig(promptPath, options);
|
||||
|
||||
// Detect if --json-schema is present in claude args
|
||||
const hasJsonSchema = options.claudeArgs?.includes("--json-schema") ?? false;
|
||||
|
||||
// Create a named pipe
|
||||
try {
|
||||
await unlink(PIPE_PATH);
|
||||
} catch (e) {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
// Create the named pipe
|
||||
await execAsync(`mkfifo "${PIPE_PATH}"`);
|
||||
|
||||
// Log prompt file size
|
||||
let promptSize = "unknown";
|
||||
try {
|
||||
const stats = await stat(config.promptPath);
|
||||
promptSize = stats.size.toString();
|
||||
} catch (e) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
||||
|
||||
// Log custom environment variables if any
|
||||
const customEnvKeys = Object.keys(config.env).filter(
|
||||
(key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT",
|
||||
);
|
||||
if (customEnvKeys.length > 0) {
|
||||
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
// Log custom arguments if any
|
||||
if (options.claudeArgs && options.claudeArgs.trim() !== "") {
|
||||
console.log(`Custom Claude arguments: ${options.claudeArgs}`);
|
||||
}
|
||||
|
||||
// Output to console
|
||||
console.log(`Running Claude with prompt from file: ${config.promptPath}`);
|
||||
console.log(`Full command: claude ${config.claudeArgs.join(" ")}`);
|
||||
|
||||
// Start sending prompt to pipe in background
|
||||
const catProcess = spawn("cat", [config.promptPath], {
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
const pipeStream = createWriteStream(PIPE_PATH);
|
||||
catProcess.stdout.pipe(pipeStream);
|
||||
|
||||
catProcess.on("error", (error) => {
|
||||
console.error("Error reading prompt file:", error);
|
||||
pipeStream.destroy();
|
||||
});
|
||||
|
||||
// Use custom executable path if provided, otherwise default to "claude"
|
||||
const claudeExecutable = options.pathToClaudeCodeExecutable || "claude";
|
||||
|
||||
const claudeProcess = spawn(claudeExecutable, config.claudeArgs, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
...config.env,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle Claude process errors
|
||||
claudeProcess.on("error", (error) => {
|
||||
console.error("Error spawning Claude process:", error);
|
||||
pipeStream.destroy();
|
||||
});
|
||||
|
||||
// Determine if full output should be shown
|
||||
// Show full output if explicitly set to "true" OR if GitHub Actions debug mode is enabled
|
||||
const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true";
|
||||
let showFullOutput = options.showFullOutput === "true" || isDebugMode;
|
||||
|
||||
if (isDebugMode && options.showFullOutput !== "false") {
|
||||
console.log("Debug mode detected - showing full output");
|
||||
showFullOutput = true;
|
||||
} else if (!showFullOutput) {
|
||||
console.log("Running Claude Code (full output hidden for security)...");
|
||||
console.log(
|
||||
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
||||
);
|
||||
}
|
||||
|
||||
// Capture output for parsing execution metrics
|
||||
let output = "";
|
||||
claudeProcess.stdout.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
// Try to parse as JSON and handle based on verbose setting
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line: string, index: number) => {
|
||||
if (line.trim() === "") return;
|
||||
|
||||
try {
|
||||
// Check if this line is a JSON object
|
||||
const parsed = JSON.parse(line);
|
||||
const sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);
|
||||
|
||||
if (sanitizedOutput) {
|
||||
process.stdout.write(sanitizedOutput);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a JSON object
|
||||
if (showFullOutput) {
|
||||
// In full output mode, print as is
|
||||
process.stdout.write(line);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
// In non-full-output mode, suppress non-JSON output
|
||||
}
|
||||
});
|
||||
|
||||
output += text;
|
||||
});
|
||||
|
||||
// Handle stdout errors
|
||||
claudeProcess.stdout.on("error", (error) => {
|
||||
console.error("Error reading Claude stdout:", error);
|
||||
});
|
||||
|
||||
// Pipe from named pipe to Claude
|
||||
const pipeProcess = spawn("cat", [PIPE_PATH]);
|
||||
pipeProcess.stdout.pipe(claudeProcess.stdin);
|
||||
|
||||
// Handle pipe process errors
|
||||
pipeProcess.on("error", (error) => {
|
||||
console.error("Error reading from named pipe:", error);
|
||||
claudeProcess.kill("SIGTERM");
|
||||
});
|
||||
|
||||
// Wait for Claude to finish
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
claudeProcess.on("close", (code) => {
|
||||
resolve(code || 0);
|
||||
});
|
||||
|
||||
claudeProcess.on("error", (error) => {
|
||||
console.error("Claude process error:", error);
|
||||
resolve(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up processes
|
||||
try {
|
||||
catProcess.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
try {
|
||||
pipeProcess.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
|
||||
// Clean up pipe file
|
||||
try {
|
||||
await unlink(PIPE_PATH);
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Set conclusion based on exit code
|
||||
if (exitCode === 0) {
|
||||
// Try to process the output and save execution metrics
|
||||
try {
|
||||
await writeFile("output.txt", output);
|
||||
|
||||
// Process output.txt into JSON and save to execution file
|
||||
// Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs
|
||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
||||
|
||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to process output for execution metrics: ${e}`);
|
||||
}
|
||||
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
|
||||
// Extract and set session_id
|
||||
await parseAndSetSessionId(EXECUTION_FILE);
|
||||
|
||||
// Parse and set structured outputs only if user provided --json-schema in claude_args
|
||||
if (hasJsonSchema) {
|
||||
try {
|
||||
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
core.setFailed(errorMessage);
|
||||
core.setOutput("conclusion", "failure");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Set conclusion to success if we reached here
|
||||
core.setOutput("conclusion", "success");
|
||||
} else {
|
||||
core.setOutput("conclusion", "failure");
|
||||
|
||||
// Still try to save execution file if we have output
|
||||
if (output) {
|
||||
try {
|
||||
await writeFile("output.txt", output);
|
||||
// Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs
|
||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
} catch (e) {
|
||||
// Ignore errors when processing output during failure
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
const parsedOptions = parseSdkOptions(options);
|
||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude";
|
||||
|
||||
describe("prepareRunConfig", () => {
|
||||
test("should prepare config with basic arguments", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include promptPath", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.promptPath).toBe("/tmp/test-prompt.txt");
|
||||
});
|
||||
|
||||
test("should use provided prompt path", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/custom/prompt/path.txt", options);
|
||||
|
||||
expect(prepared.promptPath).toBe("/custom/prompt/path.txt");
|
||||
});
|
||||
|
||||
describe("claudeArgs handling", () => {
|
||||
test("should parse and include custom claude arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: "--max-turns 10 --model claude-3-opus-20240229",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--max-turns",
|
||||
"10",
|
||||
"--model",
|
||||
"claude-3-opus-20240229",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle empty claudeArgs", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: "",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle claudeArgs with quoted strings", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: '--system-prompt "You are a helpful assistant"',
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--system-prompt",
|
||||
"You are a helpful assistant",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include json-schema flag when provided", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs:
|
||||
'--json-schema \'{"type":"object","properties":{"result":{"type":"boolean"}}}\'',
|
||||
};
|
||||
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--json-schema");
|
||||
expect(prepared.claudeArgs).toContain(
|
||||
'{"type":"object","properties":{"result":{"type":"boolean"}}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
|
||||
import { writeFile, unlink } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
parseAndSetStructuredOutputs,
|
||||
parseAndSetSessionId,
|
||||
} from "../src/run-claude";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
// Mock execution file path
|
||||
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
|
||||
|
||||
// Helper to create mock execution file with structured output
|
||||
async function createMockExecutionFile(
|
||||
structuredOutput?: Record<string, unknown>,
|
||||
includeResult: boolean = true,
|
||||
): Promise<void> {
|
||||
const messages: any[] = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "turn", content: "test" },
|
||||
];
|
||||
|
||||
if (includeResult) {
|
||||
messages.push({
|
||||
type: "result",
|
||||
cost_usd: 0.01,
|
||||
duration_ms: 1000,
|
||||
structured_output: structuredOutput,
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
}
|
||||
|
||||
// Spy on core functions
|
||||
let setOutputSpy: any;
|
||||
let infoSpy: any;
|
||||
let warningSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("parseAndSetStructuredOutputs", () => {
|
||||
afterEach(async () => {
|
||||
setOutputSpy?.mockRestore();
|
||||
infoSpy?.mockRestore();
|
||||
warningSpy?.mockRestore();
|
||||
try {
|
||||
await unlink(TEST_EXECUTION_FILE);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test("should set structured_output with valid data", async () => {
|
||||
await createMockExecutionFile({
|
||||
is_flaky: true,
|
||||
confidence: 0.85,
|
||||
summary: "Test looks flaky",
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith(
|
||||
"structured_output",
|
||||
'{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}',
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"Set structured_output with 3 field(s)",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle arrays and nested objects", async () => {
|
||||
await createMockExecutionFile({
|
||||
items: ["a", "b", "c"],
|
||||
config: { key: "value", nested: { deep: true } },
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
const callArgs = setOutputSpy.mock.calls[0];
|
||||
expect(callArgs[0]).toBe("structured_output");
|
||||
const parsed = JSON.parse(callArgs[1]);
|
||||
expect(parsed).toEqual({
|
||||
items: ["a", "b", "c"],
|
||||
config: { key: "value", nested: { deep: true } },
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle special characters in field names", async () => {
|
||||
await createMockExecutionFile({
|
||||
"test-result": "passed",
|
||||
"item.count": 10,
|
||||
"user@email": "test",
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
const callArgs = setOutputSpy.mock.calls[0];
|
||||
const parsed = JSON.parse(callArgs[1]);
|
||||
expect(parsed["test-result"]).toBe("passed");
|
||||
expect(parsed["item.count"]).toBe(10);
|
||||
expect(parsed["user@email"]).toBe("test");
|
||||
});
|
||||
|
||||
test("should throw error when result exists but structured_output is undefined", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "result", cost_usd: 0.01, duration_ms: 1000 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow(
|
||||
"--json-schema was provided but Claude did not return structured_output",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when no result message exists", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "turn", content: "test" },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow(
|
||||
"--json-schema was provided but Claude did not return structured_output",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error with malformed JSON", async () => {
|
||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should throw error when file does not exist", async () => {
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs("/nonexistent/file.json"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should handle empty structured_output object", async () => {
|
||||
await createMockExecutionFile({});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"Set structured_output with 0 field(s)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAndSetSessionId", () => {
|
||||
afterEach(async () => {
|
||||
setOutputSpy?.mockRestore();
|
||||
infoSpy?.mockRestore();
|
||||
warningSpy?.mockRestore();
|
||||
try {
|
||||
await unlink(TEST_EXECUTION_FILE);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test("should extract session_id from system.init message", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init", session_id: "test-session-123" },
|
||||
{ type: "result", cost_usd: 0.01 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
|
||||
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
|
||||
});
|
||||
|
||||
test("should handle missing session_id gracefully", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "result", cost_usd: 0.01 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle missing system.init message gracefully", async () => {
|
||||
const messages = [{ type: "result", cost_usd: 0.01 }];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle malformed JSON gracefully with warning", async () => {
|
||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle non-existent file gracefully with warning", async () => {
|
||||
await parseAndSetSessionId("/nonexistent/file.json");
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
4
bun.lock
4
bun.lock
@@ -7,7 +7,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.6",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.15",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.6", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-lwswHo6z/Kh9djafk2ajPju62+VqHwJ23gueG1alfaLNK4GRYHgCROfiX6/wlxAd8sRvgTo6ry1hNzkyz7bOpw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.15", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-KN3jrHR5tIcAfLbplK5xHqNyUS3XnG8DMnImGeVEv64Z8NxfxIWtJTxtuBRWjyYzo36PEhK4r2SkX97A2iG+ng=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ The `additional_permissions` input allows Claude to access GitHub Actions workfl
|
||||
To allow Claude to view workflow run results, job logs, and CI status:
|
||||
|
||||
1. **Grant the necessary permission to your GitHub token**:
|
||||
|
||||
- When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow:
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -228,10 +228,12 @@ jobs:
|
||||
The action now automatically detects the appropriate mode:
|
||||
|
||||
1. **If `prompt` is provided** → Runs in **automation mode**
|
||||
|
||||
- Executes immediately without waiting for @claude mentions
|
||||
- Perfect for scheduled tasks, PR automation, etc.
|
||||
|
||||
2. **If no `prompt` but @claude is mentioned** → Runs in **interactive mode**
|
||||
|
||||
- Waits for and responds to @claude mentions
|
||||
- Creates tracking comments with progress
|
||||
|
||||
|
||||
@@ -7,13 +7,22 @@
|
||||
- **⚠️ Non-Write User Access (RISKY)**: The `allowed_non_write_users` parameter allows bypassing the write permission requirement. **This is a significant security risk and should only be used for workflows with extremely limited permissions** (e.g., issue labeling workflows that only have `issues: write` permission). This feature:
|
||||
- Only works when `github_token` is provided as input (not with GitHub App authentication)
|
||||
- Accepts either a comma-separated list of specific usernames or `*` to allow all users
|
||||
- **When using the wildcard (`*`)**, you MUST also set `bypass_write_permission_check_acknowledgment: true` to explicitly acknowledge the security implications. Without this flag, the action will fail as a safeguard against accidental security misconfigurations
|
||||
- **Should be used with extreme caution** as it bypasses the primary security mechanism of this action
|
||||
- Is designed for automation workflows where user permissions are already restricted by the workflow's permission scope
|
||||
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
|
||||
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
||||
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
||||
|
||||
## Pull Request Creation
|
||||
|
||||
In its default configuration, **Claude does not create pull requests automatically** when responding to `@claude` mentions. Instead:
|
||||
|
||||
- Claude commits code changes to a new branch
|
||||
- Claude provides a **link to the GitHub PR creation page** in its response
|
||||
- **The user must click the link and create the PR themselves**, ensuring human oversight before any code is proposed for merging
|
||||
|
||||
This design ensures that users retain full control over what pull requests are created and can review the changes before initiating the PR workflow.
|
||||
|
||||
## ⚠️ Prompt Injection Risks
|
||||
|
||||
**Beware of potential hidden markdown when tagging Claude on untrusted content.** External contributors may include hidden instructions through HTML comments, invisible characters, hidden attributes, or other techniques. The action sanitizes content by stripping HTML comments, invisible characters, markdown image alt text, hidden HTML attributes, and HTML entities, but new bypass techniques may emerge. We recommend reviewing the raw content of all input coming from external contributors before allowing Claude to process it.
|
||||
@@ -76,12 +85,14 @@ Commits will show as verified and attributed to the GitHub account that owns the
|
||||
```
|
||||
|
||||
2. Add the **public key** to your GitHub account:
|
||||
|
||||
- Go to GitHub → Settings → SSH and GPG keys
|
||||
- Click "New SSH key"
|
||||
- Select **Key type: Signing Key** (important)
|
||||
- Paste the contents of `~/.ssh/signing_key.pub`
|
||||
|
||||
3. Add the **private key** to your repository secrets:
|
||||
|
||||
- Go to your repo → Settings → Secrets and variables → Actions
|
||||
- Create a new secret named `SSH_SIGNING_KEY`
|
||||
- Paste the contents of `~/.ssh/signing_key`
|
||||
|
||||
@@ -31,23 +31,27 @@ The fastest way to create a custom GitHub App is using our pre-configured manife
|
||||
**🚀 [Download the Quick Setup Tool](./create-app.html)** (Right-click → "Save Link As" or "Download Linked File")
|
||||
|
||||
After downloading, open `create-app.html` in your web browser:
|
||||
|
||||
- **For Personal Accounts:** Click the "Create App for Personal Account" button
|
||||
- **For Organizations:** Enter your organization name and click "Create App for Organization"
|
||||
|
||||
The tool will automatically configure all required permissions and submit the manifest.
|
||||
|
||||
Alternatively, you can use the manifest file directly:
|
||||
|
||||
- Use the [`github-app-manifest.json`](../github-app-manifest.json) file from this repository
|
||||
- Visit https://github.com/settings/apps/new (for personal) or your organization's app settings
|
||||
- Look for the "Create from manifest" option and paste the JSON content
|
||||
|
||||
2. **Complete the creation flow:**
|
||||
|
||||
- GitHub will show you a preview of the app configuration
|
||||
- Confirm the app name (you can customize it)
|
||||
- Click "Create GitHub App"
|
||||
- The app will be created with all required permissions automatically configured
|
||||
|
||||
3. **Generate and download a private key:**
|
||||
|
||||
- After creating the app, you'll be redirected to the app settings
|
||||
- Scroll down to "Private keys"
|
||||
- Click "Generate a private key"
|
||||
@@ -60,6 +64,7 @@ The fastest way to create a custom GitHub App is using our pre-configured manife
|
||||
If you prefer to configure the app manually or need custom permissions:
|
||||
|
||||
1. **Create a new GitHub App:**
|
||||
|
||||
- Go to https://github.com/settings/apps (for personal apps) or your organization's settings
|
||||
- Click "New GitHub App"
|
||||
- Configure the app with these minimum permissions:
|
||||
@@ -72,16 +77,19 @@ If you prefer to configure the app manually or need custom permissions:
|
||||
- Create the app
|
||||
|
||||
2. **Generate and download a private key:**
|
||||
|
||||
- After creating the app, scroll down to "Private keys"
|
||||
- Click "Generate a private key"
|
||||
- Download the `.pem` file (keep this secure!)
|
||||
|
||||
3. **Install the app on your repository:**
|
||||
|
||||
- Go to the app's settings page
|
||||
- Click "Install App"
|
||||
- Select the repositories where you want to use Claude
|
||||
|
||||
4. **Add the app credentials to your repository secrets:**
|
||||
|
||||
- Go to your repository's Settings → Secrets and variables → Actions
|
||||
- Add these secrets:
|
||||
- `APP_ID`: Your GitHub App's ID (found in the app settings)
|
||||
@@ -130,6 +138,7 @@ For more information on creating GitHub Apps, see the [GitHub documentation](htt
|
||||
To securely use your Anthropic API key:
|
||||
|
||||
1. Add your API key as a repository secret:
|
||||
|
||||
- Go to your repository's Settings
|
||||
- Navigate to "Secrets and variables" → "Actions"
|
||||
- Click "New repository secret"
|
||||
|
||||
@@ -52,36 +52,35 @@ jobs:
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
||||
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
||||
| `include_fix_links` | Include 'Fix this' links in PR code review feedback that open Claude Code with context to fix the identified issue | No | `true` |
|
||||
| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | 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` |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's API. Simple but cannot perform complex git operations like rebasing. See [Security](./security.md#commit-signing) | No | `false` |
|
||||
| `ssh_signing_key` | SSH private key for signing commits. Enables signed commits with full git CLI support (rebasing, etc.). See [Security](./security.md#commit-signing) | No | "" |
|
||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID). Required with `ssh_signing_key` for verified commits | No | `41898282` |
|
||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name). Required with `ssh_signing_key` for verified commits | No | `claude[bot]` |
|
||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
||||
| `bypass_write_permission_check_acknowledgment` | **REQUIRED** when using `allowed_non_write_users='*'`. Set to `true` to explicitly acknowledge security implications. Prevents accidental security misconfigurations | No | `false` |
|
||||
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||
| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" |
|
||||
| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" |
|
||||
| Input | Description | Required | Default |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
||||
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
||||
| `include_fix_links` | Include 'Fix this' links in PR code review feedback that open Claude Code with context to fix the identified issue | No | `true` |
|
||||
| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | 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` |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's API. Simple but cannot perform complex git operations like rebasing. See [Security](./security.md#commit-signing) | No | `false` |
|
||||
| `ssh_signing_key` | SSH private key for signing commits. Enables signed commits with full git CLI support (rebasing, etc.). See [Security](./security.md#commit-signing) | No | "" |
|
||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID). Required with `ssh_signing_key` for verified commits | No | `41898282` |
|
||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name). Required with `ssh_signing_key` for verified commits | No | `claude[bot]` |
|
||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
||||
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||
| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" |
|
||||
| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" |
|
||||
|
||||
### Deprecated Inputs
|
||||
|
||||
|
||||
@@ -26,5 +26,4 @@ jobs:
|
||||
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_non_write_users: "*" # Required for issue triage workflow, if users without repo write access create issues
|
||||
bypass_write_permission_check_acknowledgment: true # Required when using wildcard
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.6",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.15",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
|
||||
@@ -37,7 +37,6 @@ async function run() {
|
||||
context,
|
||||
context.inputs.allowedNonWriteUsers,
|
||||
githubTokenProvided,
|
||||
context.inputs.bypassWritePermissionCheckAcknowledgment,
|
||||
);
|
||||
if (!hasWritePermissions) {
|
||||
throw new Error(
|
||||
|
||||
@@ -96,7 +96,6 @@ type BaseContext = {
|
||||
botName: string;
|
||||
allowedBots: string;
|
||||
allowedNonWriteUsers: string;
|
||||
bypassWritePermissionCheckAcknowledgment: boolean;
|
||||
trackProgress: boolean;
|
||||
includeFixLinks: boolean;
|
||||
};
|
||||
@@ -155,8 +154,6 @@ export function parseGitHubContext(): GitHubContext {
|
||||
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
|
||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
|
||||
bypassWritePermissionCheckAcknowledgment:
|
||||
process.env.BYPASS_WRITE_PERMISSION_CHECK_ACKNOWLEDGMENT === "true",
|
||||
trackProgress: process.env.TRACK_PROGRESS === "true",
|
||||
includeFixLinks: process.env.INCLUDE_FIX_LINKS === "true",
|
||||
},
|
||||
|
||||
@@ -82,8 +82,13 @@ export async function setupSshSigning(sshSigningKey: string): Promise<void> {
|
||||
const sshDir = join(homedir(), ".ssh");
|
||||
await mkdir(sshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Ensure key ends with newline (required for ssh-keygen to parse it)
|
||||
const normalizedKey = sshSigningKey.endsWith("\n")
|
||||
? sshSigningKey
|
||||
: sshSigningKey + "\n";
|
||||
|
||||
// Write the signing key atomically with secure permissions (600)
|
||||
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
|
||||
await writeFile(SSH_SIGNING_KEY_PATH, normalizedKey, { mode: 0o600 });
|
||||
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
|
||||
|
||||
// Configure git to use SSH signing
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
*/
|
||||
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { GitHubContext } from "../context";
|
||||
|
||||
export async function checkHumanActor(
|
||||
octokit: Octokit,
|
||||
githubContext: ParsedGitHubContext,
|
||||
githubContext: GitHubContext,
|
||||
) {
|
||||
// Fetch user information from GitHub API
|
||||
const { data: userData } = await octokit.users.getByUsername({
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { Octokit } from "@octokit/rest";
|
||||
* @param context - The GitHub context
|
||||
* @param allowedNonWriteUsers - Comma-separated list of users allowed without write permissions, or '*' for all
|
||||
* @param githubTokenProvided - Whether github_token was provided as input (not from app)
|
||||
* @param bypassAcknowledgment - Explicit acknowledgment required when using wildcard (*)
|
||||
* @returns true if the actor has write permissions, false otherwise
|
||||
*/
|
||||
export async function checkWritePermissions(
|
||||
@@ -16,7 +15,6 @@ export async function checkWritePermissions(
|
||||
context: ParsedGitHubContext,
|
||||
allowedNonWriteUsers?: string,
|
||||
githubTokenProvided?: boolean,
|
||||
bypassAcknowledgment?: boolean,
|
||||
): Promise<boolean> {
|
||||
const { repository, actor } = context;
|
||||
|
||||
@@ -27,17 +25,6 @@ export async function checkWritePermissions(
|
||||
if (allowedNonWriteUsers && githubTokenProvided) {
|
||||
const allowedUsers = allowedNonWriteUsers.trim();
|
||||
if (allowedUsers === "*") {
|
||||
if (!bypassAcknowledgment) {
|
||||
core.error(
|
||||
`❌ SECURITY ERROR: Attempting to bypass write permission checks for all users with allowed_non_write_users='*' without explicit acknowledgment. ` +
|
||||
`This is a critical security misconfiguration. To proceed, you must set bypass_write_permission_check_acknowledgment='true' ` +
|
||||
`to explicitly acknowledge the security implications.`,
|
||||
);
|
||||
throw new Error(
|
||||
"Cannot bypass write permission checks with wildcard (*) without explicit acknowledgment. " +
|
||||
"Set bypass_write_permission_check_acknowledgment='true' to acknowledge security implications.",
|
||||
);
|
||||
}
|
||||
core.warning(
|
||||
`⚠️ SECURITY WARNING: Bypassing write permission check for ${actor} due to allowed_non_write_users='*'. This should only be used for workflows with very limited permissions.`,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
configureGitAuth,
|
||||
setupSshSigning,
|
||||
} from "../../github/operations/git-config";
|
||||
import { checkHumanActor } from "../../github/validation/actor";
|
||||
import type { GitHubContext } from "../../github/context";
|
||||
import { isEntityContext } from "../../github/context";
|
||||
|
||||
@@ -80,7 +81,14 @@ export const agentMode: Mode = {
|
||||
return false;
|
||||
},
|
||||
|
||||
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
||||
async prepare({
|
||||
context,
|
||||
octokit,
|
||||
githubToken,
|
||||
}: ModeOptions): Promise<ModeResult> {
|
||||
// Check if actor is human (prevents bot-triggered loops)
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
|
||||
// Configure git authentication for agent mode (same as tag mode)
|
||||
// SSH signing takes precedence if provided
|
||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
||||
|
||||
@@ -145,12 +145,12 @@ describe("Agent Mode", () => {
|
||||
users: {
|
||||
getAuthenticated: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345 },
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345 },
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
@@ -187,6 +187,65 @@ describe("Agent Mode", () => {
|
||||
process.env.GITHUB_REF_NAME = originalRefName;
|
||||
});
|
||||
|
||||
test("prepare method rejects bot actors without allowed_bots", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithPrompts.actor = "claude[bot]";
|
||||
contextWithPrompts.inputs.allowedBots = "";
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
users: {
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "claude[bot]", id: 12345, type: "Bot" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
agentMode.prepare({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Workflow initiated by non-human actor: claude (type: Bot)",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepare method allows bot actors when in allowed_bots list", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
contextWithPrompts.actor = "dependabot[bot]";
|
||||
contextWithPrompts.inputs.allowedBots = "dependabot";
|
||||
|
||||
const mockOctokit = {
|
||||
rest: {
|
||||
users: {
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "dependabot[bot]", id: 12345, type: "Bot" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Should not throw - bot is in allowed list
|
||||
await expect(
|
||||
agentMode.prepare({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
test("prepare method creates prompt file with correct content", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
@@ -199,12 +258,12 @@ describe("Agent Mode", () => {
|
||||
users: {
|
||||
getAuthenticated: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345 },
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
getByUsername: mock(() =>
|
||||
Promise.resolve({
|
||||
data: { login: "test-user", id: 12345 },
|
||||
data: { login: "test-user", id: 12345, type: "User" },
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -73,7 +73,6 @@ describe("checkWritePermissions", () => {
|
||||
botName: CLAUDE_BOT_LOGIN,
|
||||
allowedBots: "",
|
||||
allowedNonWriteUsers: "",
|
||||
bypassWritePermissionCheckAcknowledgment: false,
|
||||
trackProgress: false,
|
||||
includeFixLinks: true,
|
||||
},
|
||||
@@ -198,7 +197,7 @@ describe("checkWritePermissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should bypass permission check for all users with wildcard when acknowledgment provided", async () => {
|
||||
test("should bypass permission check for all users with wildcard", async () => {
|
||||
const mockOctokit = createMockOctokit("read");
|
||||
const context = createContext();
|
||||
|
||||
@@ -207,7 +206,6 @@ describe("checkWritePermissions", () => {
|
||||
context,
|
||||
"*",
|
||||
true,
|
||||
true, // acknowledgment provided
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
@@ -216,17 +214,6 @@ describe("checkWritePermissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should FAIL to bypass permission check with wildcard when acknowledgment NOT provided", async () => {
|
||||
const mockOctokit = createMockOctokit("read");
|
||||
const context = createContext();
|
||||
|
||||
await expect(
|
||||
checkWritePermissions(mockOctokit, context, "*", true, false),
|
||||
).rejects.toThrow(
|
||||
"Cannot bypass write permission checks with wildcard (*) without explicit acknowledgment",
|
||||
);
|
||||
});
|
||||
|
||||
test("should NOT bypass permission check when user not in allowed list", async () => {
|
||||
const mockOctokit = createMockOctokit("read");
|
||||
const context = createContext();
|
||||
|
||||
@@ -55,6 +55,47 @@ describe("SSH Signing", () => {
|
||||
expect(permissions).toBe(0o600);
|
||||
});
|
||||
|
||||
test("should normalize key to have trailing newline", async () => {
|
||||
// ssh-keygen requires a trailing newline to parse the key
|
||||
const keyWithoutNewline =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
|
||||
const keyWithNewline = keyWithoutNewline + "\n";
|
||||
|
||||
// Create directory
|
||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Normalize the key (same logic as setupSshSigning)
|
||||
const normalizedKey = keyWithoutNewline.endsWith("\n")
|
||||
? keyWithoutNewline
|
||||
: keyWithoutNewline + "\n";
|
||||
|
||||
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
// Verify the written key ends with newline
|
||||
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||
expect(keyContent).toBe(keyWithNewline);
|
||||
expect(keyContent.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
test("should not add extra newline if key already has one", async () => {
|
||||
const keyWithNewline =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Normalize the key (same logic as setupSshSigning)
|
||||
const normalizedKey = keyWithNewline.endsWith("\n")
|
||||
? keyWithNewline
|
||||
: keyWithNewline + "\n";
|
||||
|
||||
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
// Verify no double newline
|
||||
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||
expect(keyContent).toBe(keyWithNewline);
|
||||
expect(keyContent.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
|
||||
test("should create .ssh directory with secure permissions", async () => {
|
||||
// Clean up first
|
||||
await rm(testSshDir, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user