Compare commits

..

13 Commits

Author SHA1 Message Date
Cole Davis
b12957001c Fall back to default on duplicate branch 2025-09-16 21:46:32 -04:00
Cole Davis
60112e2967 Still replace undefined values 2025-09-16 21:16:23 -04:00
Cole Davis
ddb3ef092b Merge createBranchTemplateVariables into generateBranchName 2025-09-16 21:05:17 -04:00
Cole Davis
35c0ca2406 Clean up comments, docstrings 2025-09-16 20:55:40 -04:00
Cole Davis
1a49e00b3e Add check for empty template-generated name 2025-09-16 20:50:52 -04:00
Cole Davis
e758a32de7 Only fetch first label 2025-09-16 20:33:16 -04:00
Cole Davis
6246ecdcb5 Remove more granular time template variables 2025-09-16 20:31:18 -04:00
Cole Davis
32e7aeee0e More concise description for branch_name_template 2025-09-16 20:26:12 -04:00
Cole Davis
40c2c1e7b4 Add description template variable 2025-09-15 14:22:56 -04:00
Cole Davis
9c4bc5dc35 Add label to template variables 2025-09-15 14:22:56 -04:00
Cole Davis
b5de2b9913 Use branch name template 2025-09-15 14:22:56 -04:00
Cole Davis
4991c0c947 Logging 2025-09-15 14:22:56 -04:00
Cole Davis
726d5808b5 Add branch-name-template config option 2025-09-15 14:22:56 -04:00
46 changed files with 557 additions and 2647 deletions

View File

@@ -9,7 +9,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
@@ -24,7 +24,7 @@ jobs:
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
with:
@@ -39,7 +39,7 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:

View File

@@ -13,7 +13,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

38
.github/workflows/claude-test.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
# Test workflow for km-anthropic fork (v1-dev branch)
# This tests the fork implementation, not the main repo
name: Claude Code (Fork Test)
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (
contains(github.event.issue.body, '@claude') ||
contains(github.event.issue.title, '@claude')
))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write # Required for OIDC token exchange
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Claude Code
uses: km-anthropic/claude-code-action@v1-dev
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}

View File

@@ -25,7 +25,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -19,7 +19,7 @@ jobs:
next_version: ${{ steps.next_version.outputs.next_version }}
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -91,7 +91,7 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -116,7 +116,7 @@ jobs:
environment: production
steps:
- name: Checkout base-action repo
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
repository: anthropics/claude-code-base-action
token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}

View File

@@ -67,7 +67,7 @@ jobs:
uses: ./base-action
with:
prompt: |
Run the command `echo $HOME` to check the home directory path
Use Bash to echo "This should not work"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
settings: |
{
@@ -163,7 +163,7 @@ jobs:
uses: ./base-action
with:
prompt: |
Run the command `echo $HOME` to check the home directory path
Use Bash to echo "This should not work from file"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
settings: "test-settings.json"

View File

@@ -23,6 +23,10 @@ inputs:
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
required: false
default: "claude/"
branch_name_template:
description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 3 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'"
required: false
default: ""
allowed_bots:
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
required: false
@@ -101,18 +105,6 @@ inputs:
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
required: false
default: ""
show_full_output:
description: "Show full JSON output from Claude Code. WARNING: This outputs ALL Claude messages including tool execution results which may contain secrets, API keys, or other sensitive information. These logs are publicly visible in GitHub Actions. Only enable for debugging in non-sensitive environments."
required: false
default: "false"
plugins:
description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')"
required: false
default: ""
plugin_marketplaces:
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
required: false
default: ""
outputs:
execution_file:
@@ -162,6 +154,7 @@ runs:
LABEL_TRIGGER: ${{ inputs.label_trigger }}
BASE_BRANCH: ${{ inputs.base_branch }}
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
BRANCH_NAME_TEMPLATE: ${{ inputs.branch_name_template }}
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
@@ -189,7 +182,7 @@ runs:
# Install Claude Code if no custom executable is provided
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.29
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.113
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
@@ -225,9 +218,6 @@ runs:
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
INPUT_PLUGINS: ${{ inputs.plugins }}
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
# Model configuration
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
@@ -274,7 +264,7 @@ runs:
GITHUB_EVENT_NAME: ${{ github.event_name }}
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }}
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}

View File

@@ -85,32 +85,29 @@ 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' |
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#-full-output-security-warning)) | No | 'false'\*\* |
| Input | Description | Required | Default |
| ------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
| `prompt` | The prompt to send to Claude Code | No\* | '' |
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
| `disallowed_tools` | Comma-separated list of disallowed tools that Claude Code cannot use | No | '' |
| `max_turns` | Maximum number of conversation turns (default: no limit) | No | '' |
| `mcp_config` | Path to the MCP configuration JSON file, or MCP configuration JSON string | No | '' |
| `settings` | Path to Claude Code settings JSON file, or settings JSON string | No | '' |
| `system_prompt` | Override system prompt | No | '' |
| `append_system_prompt` | Append to system prompt | No | '' |
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML multiline format) | No | '' |
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | 'claude-4-0-sonnet-20250219' |
| `anthropic_model` | DEPRECATED: Use 'model' instead | No | 'claude-4-0-sonnet-20250219' |
| `fallback_model` | Enable automatic fallback to specified model when default model is overloaded | No | '' |
| `anthropic_api_key` | Anthropic API key (required for direct Anthropic API) | No | '' |
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No | '' |
| `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' |
\*Either `prompt` or `prompt_file` must be provided, but not both.
\*\*`show_full_output` is automatically enabled when GitHub Actions debug mode is active. See [security documentation](../docs/security.md#-full-output-security-warning) for important security considerations.
## Outputs
| Output | Description |
@@ -339,7 +336,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -55,18 +55,6 @@ inputs:
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
required: false
default: ""
show_full_output:
description: "Show full JSON output from Claude Code. WARNING: This outputs ALL Claude messages including tool execution results which may contain secrets, API keys, or other sensitive information. These logs are publicly visible in GitHub Actions. Only enable for debugging in non-sensitive environments."
required: false
default: "false"
plugins:
description: "Newline-separated list of Claude Code plugin names to install (e.g., 'code-review@claude-code-plugins\nfeature-dev@claude-code-plugins')"
required: false
default: ""
plugin_marketplaces:
description: "Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., 'https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git')"
required: false
default: ""
outputs:
conclusion:
@@ -111,7 +99,7 @@ runs:
run: |
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.29
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.113
else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
# Add the directory containing the custom executable to PATH
@@ -138,9 +126,6 @@ runs:
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
INPUT_PLUGINS: ${{ inputs.plugins }}
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
# Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}

View File

@@ -32,7 +32,7 @@ jobs:
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-23fa0dd"
"ghcr.io/github/github-mcp-server:sha-7aced2b"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -1,196 +0,0 @@
{
"name": "@anthropic-ai/claude-code-base-action",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@anthropic-ai/claude-code-base-action",
"version": "1.0.0",
"dependencies": {
"@actions/core": "^1.10.1",
"shell-quote": "^1.8.3"
},
"devDependencies": {
"@types/bun": "^1.2.12",
"@types/node": "^20.0.0",
"@types/shell-quote": "^1.7.5",
"prettier": "3.5.3",
"typescript": "^5.8.3"
}
},
"node_modules/@actions/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz",
"integrity": "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==",
"license": "MIT",
"dependencies": {
"@actions/exec": "^1.1.1",
"@actions/http-client": "^2.0.1"
}
},
"node_modules/@actions/exec": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz",
"integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==",
"license": "MIT",
"dependencies": {
"@actions/io": "^1.0.1"
}
},
"node_modules/@actions/http-client": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-2.2.3.tgz",
"integrity": "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==",
"license": "MIT",
"dependencies": {
"tunnel": "^0.0.6",
"undici": "^5.25.4"
}
},
"node_modules/@actions/io": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz",
"integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==",
"license": "MIT"
},
"node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@types/bun": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.1.tgz",
"integrity": "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bun-types": "1.3.1"
}
},
"node_modules/@types/node": {
"version": "20.19.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/shell-quote": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz",
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
"dev": true,
"license": "MIT"
},
"node_modules/bun-types": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.1.tgz",
"integrity": "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"peerDependencies": {
"@types/react": "^19"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tunnel": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
"integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
"license": "MIT",
"engines": {
"node": ">=0.6.11 <=0.7.0 || >=0.7.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -5,7 +5,6 @@ import { preparePrompt } from "./prepare-prompt";
import { runClaude } from "./run-claude";
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { validateEnvironmentVariables } from "./validate-env";
import { installPlugins } from "./install-plugins";
async function run() {
try {
@@ -16,13 +15,6 @@ async function run() {
undefined, // homeDir
);
// Install Claude Code plugins if specified
await installPlugins(
process.env.INPUT_PLUGIN_MARKETPLACES,
process.env.INPUT_PLUGINS,
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
);
const promptConfig = await preparePrompt({
prompt: process.env.INPUT_PROMPT || "",
promptFile: process.env.INPUT_PROMPT_FILE || "",
@@ -41,7 +33,6 @@ async function run() {
model: process.env.ANTHROPIC_MODEL,
pathToClaudeCodeExecutable:
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
});
} catch (error) {
core.setFailed(`Action failed with error: ${error}`);

View File

@@ -1,222 +0,0 @@
import { spawn, ChildProcess } from "child_process";
const PLUGIN_NAME_REGEX = /^[@a-zA-Z0-9_\-\/\.]+$/;
const MAX_PLUGIN_NAME_LENGTH = 512;
const PATH_TRAVERSAL_REGEX =
/\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![0-9])/;
const MARKETPLACE_URL_REGEX =
/^https:\/\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]+\.git$/;
/**
* Validates a marketplace URL for security issues
* @param url - The marketplace URL to validate
* @throws {Error} If the URL is invalid
*/
function validateMarketplaceUrl(url: string): void {
const normalized = url.trim();
if (!normalized) {
throw new Error("Marketplace URL cannot be empty");
}
if (!MARKETPLACE_URL_REGEX.test(normalized)) {
throw new Error(`Invalid marketplace URL format: ${url}`);
}
// Additional check for valid URL structure
try {
new URL(normalized);
} catch {
throw new Error(`Invalid marketplace URL: ${url}`);
}
}
/**
* Validates a plugin name for security issues
* @param pluginName - The plugin name to validate
* @throws {Error} If the plugin name is invalid
*/
function validatePluginName(pluginName: string): void {
// Normalize Unicode to prevent homoglyph attacks (e.g., fullwidth dots, Unicode slashes)
const normalized = pluginName.normalize("NFC");
if (normalized.length > MAX_PLUGIN_NAME_LENGTH) {
throw new Error(`Plugin name too long: ${normalized.substring(0, 50)}...`);
}
if (!PLUGIN_NAME_REGEX.test(normalized)) {
throw new Error(`Invalid plugin name format: ${pluginName}`);
}
// Prevent path traversal attacks with single efficient regex check
if (PATH_TRAVERSAL_REGEX.test(normalized)) {
throw new Error(`Invalid plugin name format: ${pluginName}`);
}
}
/**
* Parse a newline-separated list of marketplace URLs and return an array of validated URLs
* @param marketplaces - Newline-separated list of marketplace Git URLs
* @returns Array of validated marketplace URLs (empty array if none provided)
*/
function parseMarketplaces(marketplaces?: string): string[] {
const trimmed = marketplaces?.trim();
if (!trimmed) {
return [];
}
// Split by newline and process each URL
return trimmed
.split("\n")
.map((url) => url.trim())
.filter((url) => {
if (url.length === 0) return false;
validateMarketplaceUrl(url);
return true;
});
}
/**
* Parse a newline-separated list of plugin names and return an array of trimmed, non-empty plugin names
* Validates plugin names to prevent command injection and path traversal attacks
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
* Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots
* @param plugins - Newline-separated list of plugin names, or undefined/empty to return empty array
* @returns Array of validated plugin names (empty array if none provided)
* @throws {Error} If any plugin name fails validation
*/
function parsePlugins(plugins?: string): string[] {
const trimmedPlugins = plugins?.trim();
if (!trimmedPlugins) {
return [];
}
// Split by newline and process each plugin
return trimmedPlugins
.split("\n")
.map((p) => p.trim())
.filter((p) => {
if (p.length === 0) return false;
validatePluginName(p);
return true;
});
}
/**
* Executes a Claude Code CLI command with proper error handling
* @param claudeExecutable - Path to the Claude executable
* @param args - Command arguments to pass to the executable
* @param errorContext - Context string for error messages (e.g., "Failed to install plugin 'foo'")
* @returns Promise that resolves when the command completes successfully
* @throws {Error} If the command fails to execute
*/
async function executeClaudeCommand(
claudeExecutable: string,
args: string[],
errorContext: string,
): Promise<void> {
return new Promise((resolve, reject) => {
const childProcess: ChildProcess = spawn(claudeExecutable, args, {
stdio: "inherit",
});
childProcess.on("close", (code: number | null) => {
if (code === 0) {
resolve();
} else if (code === null) {
reject(new Error(`${errorContext}: process terminated by signal`));
} else {
reject(new Error(`${errorContext} (exit code: ${code})`));
}
});
childProcess.on("error", (err: Error) => {
reject(new Error(`${errorContext}: ${err.message}`));
});
});
}
/**
* Installs a single Claude Code plugin
* @param pluginName - The name of the plugin to install
* @param claudeExecutable - Path to the Claude executable
* @returns Promise that resolves when the plugin is installed successfully
* @throws {Error} If the plugin installation fails
*/
async function installPlugin(
pluginName: string,
claudeExecutable: string,
): Promise<void> {
console.log(`Installing plugin: ${pluginName}`);
return executeClaudeCommand(
claudeExecutable,
["plugin", "install", pluginName],
`Failed to install plugin '${pluginName}'`,
);
}
/**
* Adds a Claude Code plugin marketplace
* @param claudeExecutable - Path to the Claude executable
* @param marketplaceUrl - The marketplace Git URL to add
* @returns Promise that resolves when the marketplace add command completes
* @throws {Error} If the command fails to execute
*/
async function addMarketplace(
claudeExecutable: string,
marketplaceUrl: string,
): Promise<void> {
console.log(`Adding marketplace: ${marketplaceUrl}`);
return executeClaudeCommand(
claudeExecutable,
["plugin", "marketplace", "add", marketplaceUrl],
`Failed to add marketplace '${marketplaceUrl}'`,
);
}
/**
* Installs Claude Code plugins from a newline-separated list
* @param marketplacesInput - Newline-separated list of marketplace Git URLs
* @param pluginsInput - Newline-separated list of plugin names
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
* @returns Promise that resolves when all plugins are installed
* @throws {Error} If any plugin fails validation or installation (stops on first error)
*/
export async function installPlugins(
marketplacesInput?: string,
pluginsInput?: string,
claudeExecutable?: string,
): Promise<void> {
// Resolve executable path with explicit fallback
const resolvedExecutable = claudeExecutable || "claude";
// Parse and add all marketplaces before installing plugins
const marketplaces = parseMarketplaces(marketplacesInput);
if (marketplaces.length > 0) {
console.log(`Adding ${marketplaces.length} marketplace(s)...`);
for (const marketplace of marketplaces) {
await addMarketplace(resolvedExecutable, marketplace);
console.log(`✓ Successfully added marketplace: ${marketplace}`);
}
} else {
console.log("No marketplaces specified, skipping marketplace setup");
}
const plugins = parsePlugins(pluginsInput);
if (plugins.length > 0) {
console.log(`Installing ${plugins.length} plugin(s)...`);
for (const plugin of plugins) {
await installPlugin(plugin, resolvedExecutable);
console.log(`✓ Successfully installed: ${plugin}`);
}
} else {
console.log("No plugins specified, skipping plugins installation");
}
}

View File

@@ -12,59 +12,6 @@ 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,7 +24,6 @@ export type ClaudeOptions = {
appendSystemPrompt?: string;
claudeEnv?: string;
fallbackModel?: string;
showFullOutput?: string;
};
type PreparedConfig = {
@@ -192,27 +138,12 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
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
// Try to parse as JSON and pretty print if it's on a single line
const lines = text.split("\n");
lines.forEach((line: string, index: number) => {
if (line.trim() === "") return;
@@ -220,24 +151,17 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
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");
}
const prettyJson = JSON.stringify(parsed, null, 2);
process.stdout.write(prettyJson);
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");
}
// Not a JSON object, 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
}
});

View File

@@ -1,599 +0,0 @@
#!/usr/bin/env bun
import { describe, test, expect, mock, spyOn, afterEach } from "bun:test";
import { installPlugins } from "../src/install-plugins";
import * as childProcess from "child_process";
describe("installPlugins", () => {
let spawnSpy: ReturnType<typeof spyOn> | undefined;
afterEach(() => {
// Restore original spawn after each test
if (spawnSpy) {
spawnSpy.mockRestore();
}
});
function createMockSpawn(
exitCode: number | null = 0,
shouldError: boolean = false,
) {
const mockProcess = {
on: mock((event: string, handler: Function) => {
if (event === "close" && !shouldError) {
// Simulate successful close
setTimeout(() => handler(exitCode), 0);
} else if (event === "error" && shouldError) {
// Simulate error
setTimeout(() => handler(new Error("spawn error")), 0);
}
return mockProcess;
}),
};
spawnSpy = spyOn(childProcess, "spawn").mockImplementation(
() => mockProcess as any,
);
return spawnSpy;
}
test("should not call spawn when no plugins are specified", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, "");
expect(spy).not.toHaveBeenCalled();
});
test("should not call spawn when plugins is undefined", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, undefined);
expect(spy).not.toHaveBeenCalled();
});
test("should not call spawn when plugins is only whitespace", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, " ");
expect(spy).not.toHaveBeenCalled();
});
test("should install a single plugin with default executable", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, "test-plugin");
expect(spy).toHaveBeenCalledTimes(1);
// Only call: install plugin (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should install multiple plugins sequentially", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, "plugin1\nplugin2\nplugin3");
expect(spy).toHaveBeenCalledTimes(3);
// Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "plugin1"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin2"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
3,
"claude",
["plugin", "install", "plugin3"],
{ stdio: "inherit" },
);
});
test("should use custom claude executable path when provided", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, "test-plugin", "/custom/path/to/claude");
expect(spy).toHaveBeenCalledTimes(1);
// Only call: install plugin (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"/custom/path/to/claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should trim whitespace from plugin names before installation", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, " plugin1 \n plugin2 ");
expect(spy).toHaveBeenCalledTimes(2);
// Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "plugin1"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin2"],
{ stdio: "inherit" },
);
});
test("should skip empty entries in plugin list", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, "plugin1\n\nplugin2");
expect(spy).toHaveBeenCalledTimes(2);
// Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "plugin1"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin2"],
{ stdio: "inherit" },
);
});
test("should handle plugin installation error and throw", async () => {
createMockSpawn(1, false); // Exit code 1
await expect(installPlugins(undefined, "failing-plugin")).rejects.toThrow(
"Failed to install plugin 'failing-plugin' (exit code: 1)",
);
});
test("should handle null exit code (process terminated by signal)", async () => {
createMockSpawn(null, false); // Exit code null (terminated by signal)
await expect(
installPlugins(undefined, "terminated-plugin"),
).rejects.toThrow(
"Failed to install plugin 'terminated-plugin': process terminated by signal",
);
});
test("should stop installation on first error", async () => {
const spy = createMockSpawn(1, false); // Exit code 1
await expect(
installPlugins(undefined, "plugin1\nplugin2\nplugin3"),
).rejects.toThrow("Failed to install plugin 'plugin1' (exit code: 1)");
// Should only try to install first plugin before failing
expect(spy).toHaveBeenCalledTimes(1);
});
test("should handle plugins with special characters in names", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, "org/plugin-name\n@scope/plugin");
expect(spy).toHaveBeenCalledTimes(2);
// Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "org/plugin-name"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "@scope/plugin"],
{ stdio: "inherit" },
);
});
test("should handle spawn errors", async () => {
createMockSpawn(0, true); // Trigger error event
await expect(installPlugins(undefined, "test-plugin")).rejects.toThrow(
"Failed to install plugin 'test-plugin': spawn error",
);
});
test("should install plugins with custom executable and multiple plugins", async () => {
const spy = createMockSpawn();
await installPlugins(
undefined,
"plugin-a\nplugin-b",
"/usr/local/bin/claude-custom",
);
expect(spy).toHaveBeenCalledTimes(2);
// Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"/usr/local/bin/claude-custom",
["plugin", "install", "plugin-a"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"/usr/local/bin/claude-custom",
["plugin", "install", "plugin-b"],
{ stdio: "inherit" },
);
});
test("should reject plugin names with command injection attempts", async () => {
const spy = createMockSpawn();
// Should throw due to invalid characters (semicolon and spaces)
await expect(
installPlugins(undefined, "plugin-name; rm -rf /"),
).rejects.toThrow("Invalid plugin name format");
// Mock should never be called because validation fails first
expect(spy).not.toHaveBeenCalled();
});
test("should reject plugin names with path traversal using ../", async () => {
const spy = createMockSpawn();
await expect(
installPlugins(undefined, "../../../malicious-plugin"),
).rejects.toThrow("Invalid plugin name format");
expect(spy).not.toHaveBeenCalled();
});
test("should reject plugin names with path traversal using ./", async () => {
const spy = createMockSpawn();
await expect(
installPlugins(undefined, "./../../@scope/package"),
).rejects.toThrow("Invalid plugin name format");
expect(spy).not.toHaveBeenCalled();
});
test("should reject plugin names with consecutive dots", async () => {
const spy = createMockSpawn();
await expect(installPlugins(undefined, ".../.../package")).rejects.toThrow(
"Invalid plugin name format",
);
expect(spy).not.toHaveBeenCalled();
});
test("should reject plugin names with hidden path traversal", async () => {
const spy = createMockSpawn();
await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
"Invalid plugin name format",
);
expect(spy).not.toHaveBeenCalled();
});
test("should accept plugin names with single dots in version numbers", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, "plugin-v1.0.2");
expect(spy).toHaveBeenCalledTimes(1);
// Only call: install plugin (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "plugin-v1.0.2"],
{ stdio: "inherit" },
);
});
test("should accept plugin names with multiple dots in semantic versions", async () => {
const spy = createMockSpawn();
await installPlugins(undefined, "@scope/plugin-v1.0.0-beta.1");
expect(spy).toHaveBeenCalledTimes(1);
// Only call: install plugin (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "@scope/plugin-v1.0.0-beta.1"],
{ stdio: "inherit" },
);
});
test("should reject Unicode homoglyph path traversal attempts", async () => {
const spy = createMockSpawn();
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
await expect(installPlugins(undefined, "malicious")).rejects.toThrow(
"Invalid plugin name format",
);
expect(spy).not.toHaveBeenCalled();
});
test("should reject path traversal at end of path", async () => {
const spy = createMockSpawn();
await expect(installPlugins(undefined, "package/..")).rejects.toThrow(
"Invalid plugin name format",
);
expect(spy).not.toHaveBeenCalled();
});
test("should reject single dot directory reference", async () => {
const spy = createMockSpawn();
await expect(installPlugins(undefined, "package/.")).rejects.toThrow(
"Invalid plugin name format",
);
expect(spy).not.toHaveBeenCalled();
});
test("should reject path traversal in middle of path", async () => {
const spy = createMockSpawn();
await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
"Invalid plugin name format",
);
expect(spy).not.toHaveBeenCalled();
});
// Marketplace functionality tests
test("should add a single marketplace before installing plugins", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/marketplace.git",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(2);
// First call: add marketplace
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
// Second call: install plugin
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should add multiple marketplaces with newline separation", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/m1.git\nhttps://github.com/user/m2.git",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces + 1 plugin
// First two calls: add marketplaces
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/m1.git"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/m2.git"],
{ stdio: "inherit" },
);
// Third call: install plugin
expect(spy).toHaveBeenNthCalledWith(
3,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should add marketplaces before installing multiple plugins", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/marketplace.git",
"plugin1\nplugin2",
);
expect(spy).toHaveBeenCalledTimes(3); // 1 marketplace + 2 plugins
// First call: add marketplace
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
// Next calls: install plugins
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin1"],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
3,
"claude",
["plugin", "install", "plugin2"],
{ stdio: "inherit" },
);
});
test("should handle only marketplaces without plugins", async () => {
const spy = createMockSpawn();
await installPlugins("https://github.com/user/marketplace.git", undefined);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
});
test("should skip empty marketplace entries", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/m1.git\n\nhttps://github.com/user/m2.git",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(3); // 2 marketplaces (skip empty) + 1 plugin
});
test("should trim whitespace from marketplace URLs", async () => {
const spy = createMockSpawn();
await installPlugins(
" https://github.com/user/marketplace.git \n https://github.com/user/m2.git ",
"test-plugin",
);
expect(spy).toHaveBeenCalledTimes(3);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "marketplace", "add", "https://github.com/user/m2.git"],
{ stdio: "inherit" },
);
});
test("should reject invalid marketplace URL format", async () => {
const spy = createMockSpawn();
await expect(
installPlugins("not-a-valid-url", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");
expect(spy).not.toHaveBeenCalled();
});
test("should reject marketplace URL without .git extension", async () => {
const spy = createMockSpawn();
await expect(
installPlugins("https://github.com/user/marketplace", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");
expect(spy).not.toHaveBeenCalled();
});
test("should reject marketplace URL with non-https protocol", async () => {
const spy = createMockSpawn();
await expect(
installPlugins("http://github.com/user/marketplace.git", "test-plugin"),
).rejects.toThrow("Invalid marketplace URL format");
expect(spy).not.toHaveBeenCalled();
});
test("should skip whitespace-only marketplace input", async () => {
const spy = createMockSpawn();
await installPlugins(" ", "test-plugin");
// Should skip marketplaces and only install plugin
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenNthCalledWith(
1,
"claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
test("should handle marketplace addition error", async () => {
createMockSpawn(1, false); // Exit code 1
await expect(
installPlugins("https://github.com/user/marketplace.git", "test-plugin"),
).rejects.toThrow(
"Failed to add marketplace 'https://github.com/user/marketplace.git' (exit code: 1)",
);
});
test("should stop if marketplace addition fails before installing plugins", async () => {
const spy = createMockSpawn(1, false); // Exit code 1
await expect(
installPlugins(
"https://github.com/user/marketplace.git",
"plugin1\nplugin2",
),
).rejects.toThrow("Failed to add marketplace");
// Should only try to add marketplace, not install any plugins
expect(spy).toHaveBeenCalledTimes(1);
});
test("should use custom executable for marketplace operations", async () => {
const spy = createMockSpawn();
await installPlugins(
"https://github.com/user/marketplace.git",
"test-plugin",
"/custom/path/to/claude",
);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy).toHaveBeenNthCalledWith(
1,
"/custom/path/to/claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/user/marketplace.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"/custom/path/to/claude",
["plugin", "install", "test-plugin"],
{ stdio: "inherit" },
);
});
});

View File

@@ -1,744 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Create Claude Code GitHub App</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
/* Claude Brand Colors */
--primary-dark: #0e0e0e;
--primary-light: #d4a27f;
--background-light: rgb(253, 253, 247);
--background-dark: rgb(9, 9, 11);
--text-primary: #1a1a1a;
--text-secondary: #525252;
--text-tertiary: #737373;
--border-color: rgba(0, 0, 0, 0.08);
--hover-bg: rgba(0, 0, 0, 0.02);
--success: #2ea44f;
--warning: #e3b341;
--card-shadow:
0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--card-shadow-hover:
0 4px 6px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.05);
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
background: var(--background-light);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 40px 24px;
}
/* Header */
header {
text-align: center;
margin-bottom: 48px;
}
h1 {
font-size: 36px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
letter-spacing: -0.02em;
}
.subtitle {
font-size: 18px;
color: var(--text-secondary);
max-width: 640px;
margin: 0 auto;
line-height: 1.5;
}
/* Cards */
.card {
background: white;
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 32px;
margin-bottom: 24px;
box-shadow: var(--card-shadow);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: var(--card-shadow-hover);
}
.card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.card-icon {
font-size: 24px;
line-height: 1;
}
h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
letter-spacing: -0.01em;
}
.card-description {
color: var(--text-secondary);
margin-bottom: 24px;
font-size: 15px;
line-height: 1.6;
}
/* Buttons */
.button-group {
display: flex;
flex-direction: column;
gap: 16px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
font-size: 15px;
font-weight: 500;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
font-family: inherit;
width: 100%;
}
.btn-primary {
background: var(--primary-dark);
color: white;
}
.btn-primary:hover {
background: #1a1a1a;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.btn-secondary {
background: var(--primary-light);
color: var(--primary-dark);
}
.btn-secondary:hover {
background: #c99a70;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(212, 162, 127, 0.3);
}
.btn-outline {
background: white;
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-outline:hover {
background: var(--hover-bg);
border-color: var(--text-secondary);
}
.btn:active {
transform: translateY(0);
}
.btn.copied {
background: var(--success);
color: white;
}
/* Form */
.form-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.form-group {
flex: 1;
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 6px;
}
input[type="text"] {
width: 100%;
padding: 10px 14px;
font-size: 15px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-family: inherit;
transition: all 0.2s ease;
background: white;
}
input[type="text"]:focus {
outline: none;
border-color: var(--primary-dark);
box-shadow: 0 0 0 3px rgba(14, 14, 14, 0.1);
}
/* Code Block */
.code-container {
position: relative;
background: #fafafa;
border: 1px solid var(--border-color);
border-radius: 8px;
margin: 20px 0;
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
}
.code-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.copy-btn {
padding: 6px 12px;
font-size: 13px;
font-weight: 500;
background: white;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-btn:hover {
background: var(--hover-bg);
}
.copy-btn.copied {
background: var(--success);
color: white;
border-color: var(--success);
}
.code-block {
padding: 16px;
overflow-x: auto;
font-family:
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
"Courier New", monospace;
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre;
}
/* Permissions List */
.permissions-grid {
display: grid;
gap: 12px;
margin-top: 16px;
}
.permission-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #fafafa;
border-radius: 8px;
font-size: 14px;
}
.permission-icon {
color: var(--success);
font-size: 16px;
line-height: 1;
}
.permission-name {
font-weight: 500;
color: var(--text-primary);
}
.permission-value {
margin-left: auto;
color: var(--text-secondary);
font-size: 13px;
}
/* Steps */
.steps {
margin: 24px 0;
}
.step {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.step-number {
flex-shrink: 0;
width: 28px;
height: 28px;
background: var(--primary-dark);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
}
.step-content {
flex: 1;
padding-top: 2px;
}
.step-content p {
color: var(--text-secondary);
font-size: 15px;
line-height: 1.6;
}
.step-content strong {
color: var(--text-primary);
font-weight: 500;
}
/* Alert Box */
.alert {
display: flex;
gap: 12px;
padding: 16px;
background: #fffbf0;
border: 1px solid #f5e7c3;
border-radius: 8px;
margin-top: 32px;
}
.alert-icon {
font-size: 18px;
line-height: 1;
flex-shrink: 0;
}
.alert-content {
flex: 1;
font-size: 14px;
line-height: 1.6;
}
.alert-content strong {
color: var(--text-primary);
font-weight: 600;
}
/* Responsive */
@media (min-width: 640px) {
.button-group {
flex-direction: row;
}
.btn {
width: auto;
}
.permissions-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
h1 {
font-size: 28px;
}
.subtitle {
font-size: 16px;
}
.card {
padding: 24px 20px;
}
.container {
padding: 24px 16px;
}
}
/* Hidden form elements */
.hidden-form {
display: none;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Create Your Custom GitHub App</h1>
<p class="subtitle">
Set up a custom GitHub App for Claude Code Action with all required
permissions automatically configured.
</p>
</header>
<!-- Quick Setup Card -->
<div class="card">
<div class="card-header">
<span class="card-icon">🚀</span>
<h2>Quick Setup</h2>
</div>
<p class="card-description">
Create your GitHub App with one click. All permissions will be
automatically configured for Claude Code Action.
</p>
<div class="button-group">
<!-- Personal Account Button -->
<form
action="https://github.com/settings/apps/new"
method="post"
class="hidden-form"
id="personal-form"
>
<input type="hidden" name="manifest" id="personal-manifest" />
</form>
<button
type="button"
class="btn btn-primary"
onclick="submitPersonalForm()"
>
<span>👤</span>
<span>Create for Personal Account</span>
</button>
<!-- Organization Form -->
<form id="org-form" method="post" class="hidden-form">
<input type="hidden" name="manifest" id="org-manifest" />
</form>
</div>
<!-- Organization Input -->
<div
style="
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border-color);
"
>
<label for="org-name" style="margin-bottom: 8px"
>Or create for an organization:</label
>
<div class="form-row">
<div class="form-group">
<input
type="text"
id="org-name"
placeholder="Enter organization name (e.g., my-org)"
/>
</div>
<button
type="button"
class="btn btn-secondary"
onclick="submitOrgForm()"
style="flex-shrink: 0"
>
<span>🏢</span>
<span>Create for Org</span>
</button>
</div>
</div>
</div>
<!-- Permissions Card -->
<div class="card">
<div class="card-header">
<span class="card-icon"></span>
<h2>Configured Permissions</h2>
</div>
<p class="card-description">
Your GitHub App will be created with these permissions:
</p>
<div class="permissions-grid">
<div class="permission-item">
<span class="permission-icon"></span>
<span class="permission-name">Contents</span>
<span class="permission-value">Read & Write</span>
</div>
<div class="permission-item">
<span class="permission-icon"></span>
<span class="permission-name">Issues</span>
<span class="permission-value">Read & Write</span>
</div>
<div class="permission-item">
<span class="permission-icon"></span>
<span class="permission-name">Pull Requests</span>
<span class="permission-value">Read & Write</span>
</div>
<div class="permission-item">
<span class="permission-icon"></span>
<span class="permission-name">Actions</span>
<span class="permission-value">Read</span>
</div>
<div class="permission-item">
<span class="permission-icon"></span>
<span class="permission-name">Metadata</span>
<span class="permission-value">Read</span>
</div>
</div>
</div>
<!-- Next Steps Card -->
<div class="card">
<div class="card-header">
<span class="card-icon">📋</span>
<h2>Next Steps</h2>
</div>
<p class="card-description">
After creating your app, complete these steps:
</p>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<p>
<strong>Generate a private key:</strong> In your app settings,
scroll to "Private keys" and click "Generate a private key"
</p>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<p>
<strong>Install the app:</strong> Click "Install App" and select
the repositories where you want to use Claude
</p>
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<p>
<strong>Configure your workflow:</strong> Add your app's ID and
private key to your repository secrets
</p>
</div>
</div>
</div>
</div>
<!-- Manual Setup Card -->
<div class="card">
<div class="card-header">
<span class="card-icon">⚙️</span>
<h2>Manual Setup</h2>
</div>
<p class="card-description">
If the buttons above don't work, you can manually create the app by
copying the manifest JSON below:
</p>
<div class="code-container">
<div class="code-header">
<span class="code-label">github-app-manifest.json</span>
<button class="copy-btn" onclick="copyManifest()">Copy</button>
</div>
<div class="code-block" id="manifest-json"></div>
</div>
<div class="steps">
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<p>Copy the manifest JSON above</p>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<p>
Go to
<a
href="https://github.com/settings/apps/new"
target="_blank"
style="color: var(--primary-dark); text-decoration: underline"
>GitHub App Settings</a
>
</p>
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<p>Look for "Create from manifest" option and paste the JSON</p>
</div>
</div>
</div>
</div>
<!-- Warning Alert -->
<div class="alert">
<span class="alert-icon">⚠️</span>
<div class="alert-content">
<strong>Important:</strong> Keep your private key secure! Never commit
it to your repository. Always use GitHub secrets to store sensitive
credentials.
</div>
</div>
</div>
<script>
// Manifest configuration
const manifest = {
name: "Claude Code Custom App",
description:
"Custom GitHub App for Claude Code Action - AI-powered coding assistant for GitHub workflows",
url: "https://github.com/anthropics/claude-code-action",
hook_attributes: {
url: "https://example.com/github/webhook",
active: false,
},
redirect_url: "https://github.com/settings/apps/new",
callback_urls: [],
setup_url:
"https://github.com/anthropics/claude-code-action/blob/main/docs/setup.md",
public: false,
default_permissions: {
contents: "write",
issues: "write",
pull_requests: "write",
actions: "read",
metadata: "read",
},
default_events: [
"issue_comment",
"issues",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
],
};
// Populate manifest fields
const manifestJson = JSON.stringify(manifest);
const manifestJsonPretty = JSON.stringify(manifest, null, 2);
document.getElementById("personal-manifest").value = manifestJson;
document.getElementById("org-manifest").value = manifestJson;
// Display formatted JSON
const manifestDisplay = document.getElementById("manifest-json");
manifestDisplay.textContent = manifestJsonPretty;
// Submit personal form
function submitPersonalForm() {
document.getElementById("personal-form").submit();
}
// Submit organization form
function submitOrgForm() {
const orgName = document.getElementById("org-name").value.trim();
if (!orgName) {
alert("Please enter an organization name");
document.getElementById("org-name").focus();
return;
}
const form = document.getElementById("org-form");
form.action = `https://github.com/organizations/${orgName}/settings/apps/new`;
form.submit();
}
// Allow Enter key to submit org form
document
.getElementById("org-name")
.addEventListener("keypress", function (e) {
if (e.key === "Enter") {
e.preventDefault();
submitOrgForm();
}
});
// Copy manifest to clipboard
function copyManifest() {
navigator.clipboard
.writeText(manifestJsonPretty)
.then(() => {
const button = document.querySelector(".copy-btn");
const originalText = button.textContent;
button.textContent = "Copied!";
button.classList.add("copied");
setTimeout(() => {
button.textContent = originalText;
button.classList.remove("copied");
}, 2000);
})
.catch(() => {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = manifestJsonPretty;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
const button = document.querySelector(".copy-btn");
const originalText = button.textContent;
button.textContent = "Copied!";
button.classList.add("copied");
setTimeout(() => {
button.textContent = originalText;
button.classList.remove("copied");
}, 2000);
} catch (err) {
alert("Failed to copy. Please copy manually.");
}
document.body.removeChild(textArea);
});
}
</script>
</body>
</html>

View File

@@ -15,7 +15,7 @@ The action automatically detects which mode to use based on your configuration:
This action supports the following GitHub events ([learn more GitHub event triggers](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)):
- `pull_request` or `pull_request_target` - When PRs are opened or synchronized
- `pull_request` - When PRs are opened or synchronized
- `issue_comment` - When comments are created on issues or PRs
- `pull_request_comment` - When comments are made on PR diffs
- `issues` - When issues are opened or assigned

View File

@@ -127,7 +127,7 @@ For performance, Claude uses shallow clones:
If you need full history, you can configure this in your workflow before calling Claude in the `actions/checkout` step.
```
- uses: actions/checkout@v5
- uses: actions/checkout@v4
depth: 0 # will fetch full repo history
```

View File

@@ -13,28 +13,13 @@
- **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
## ⚠️ 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.
## GitHub App Permissions
The [Claude Code GitHub app](https://github.com/apps/claude) requests the following permissions:
The [Claude Code GitHub app](https://github.com/apps/claude) requires these permissions:
### Currently Used Permissions
- **Contents** (Read & Write): For reading repository files and creating branches
- **Pull Requests** (Read & Write): For reading PR data and creating/updating pull requests
- **Issues** (Read & Write): For reading issue data and updating issue comments
### Permissions for Future Features
The following permissions are requested but not yet actively used. These will enable planned features in future releases:
- **Discussions** (Read & Write): For interaction with GitHub Discussions
- **Actions** (Read): For accessing workflow run data and logs
- **Checks** (Read): For reading check run results
- **Workflows** (Read & Write): For triggering and managing GitHub Actions workflows
- **Pull Requests**: Read and write to create PRs and push changes
- **Issues**: Read and write to respond to issues
- **Contents**: Read and write to modify repository files
## Commit Signing
@@ -56,31 +41,3 @@ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
claude_code_oauth_token: "oauth_token_..." # Exposed and vulnerable!
```
## ⚠️ Full Output Security Warning
The `show_full_output` option is **disabled by default** for security reasons. When enabled, it outputs ALL Claude Code messages including:
- Full outputs from tool executions (e.g., `ps`, `env`, file reads)
- API responses that may contain tokens or credentials
- File contents that may include secrets
- Command outputs that may expose sensitive system information
**These logs are publicly visible in GitHub Actions for public repositories!**
### Automatic Enabling in Debug Mode
Full output is **automatically enabled** when GitHub Actions debug mode is active (when `ACTIONS_STEP_DEBUG` secret is set to `true`). This helps with debugging but carries the same security risks.
### When to Enable Full Output
Only enable `show_full_output: true` or GitHub Actions debug mode when:
- Working in a private repository with controlled access
- Debugging issues in a non-production environment
- You have verified no secrets will be exposed in the output
- You understand the security implications
### Recommended Practice
For debugging, prefer using `show_full_output: false` (the default) and rely on Claude Code's sanitized output, which shows only essential information like errors and completion status without exposing sensitive data.

View File

@@ -20,48 +20,7 @@ If you prefer not to install the official Claude app, you can create your own Gi
- Organization policies prevent installing third-party apps
- You're using AWS Bedrock or Google Vertex AI
### Option 1: Quick Setup with App Manifest (Recommended)
The fastest way to create a custom GitHub App is using our pre-configured manifest. This ensures all permissions are correctly set up with a single click.
**Steps:**
1. **Create the app:**
**🚀 [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"
- Download the `.pem` file (keep this secure!)
4. **Continue with installation** - Skip to step 3 in the manual setup below to install the app and configure your workflow.
### Option 2: Manual Setup
If you prefer to configure the app manually or need custom permissions:
**Steps to create and use a custom GitHub App:**
1. **Create a new GitHub App:**

View File

@@ -35,7 +35,7 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -89,7 +89,7 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -153,7 +153,7 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -211,7 +211,7 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -268,7 +268,7 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 1
@@ -344,7 +344,7 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -456,7 +456,7 @@ jobs:
pull-requests: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
@@ -513,7 +513,7 @@ jobs:
security-events: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -32,11 +32,6 @@ jobs:
# --max-turns 10
# --model claude-4-0-sonnet-20250805
# Optional: add custom plugin marketplaces
# plugin_marketplaces: "https://github.com/user/marketplace1.git\nhttps://github.com/user/marketplace2.git"
# Optional: install Claude Code plugins
# plugins: "code-review@claude-code-plugins\nfeature-dev@claude-code-plugins"
# Optional: add custom trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: add assignee trigger for issues
@@ -52,34 +47,33 @@ 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` |
| `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 | "" |
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | 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 | "" |
| 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` |
| `claude_args` | Additional arguments to pass directly to Claude CLI (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` |
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
| `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 | "" |
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | 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 | "" |
### Deprecated Inputs
@@ -96,7 +90,6 @@ These inputs are deprecated and will be removed in a future version:
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
| `allowed_tools` | **DEPRECATED**: Use `claude_args` with `--allowedTools` instead | Use `claude_args: "--allowedTools Edit,Read,Write"` |
| `disallowed_tools` | **DEPRECATED**: Use `claude_args` with `--disallowedTools` instead | Use `claude_args: "--disallowedTools WebSearch"` |
| `mcp_config` | **DEPRECATED**: Use `claude_args` with `--mcp-config` instead | Use `claude_args: "--mcp-config '{...}'"` |
| `claude_env` | **DEPRECATED**: Use `settings` with env configuration | Configure environment in `settings` JSON |
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_branch }}
fetch-depth: 0

View File

@@ -26,7 +26,7 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 0

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need at least 2 commits to analyze the latest

View File

@@ -16,7 +16,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -18,7 +18,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -19,7 +19,7 @@ jobs:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
with:
fetch-depth: 1

View File

@@ -1,27 +0,0 @@
{
"name": "Claude Code Custom App",
"description": "Custom GitHub App for Claude Code Action - AI-powered coding assistant for GitHub workflows",
"url": "https://github.com/anthropics/claude-code-action",
"hook_attributes": {
"url": "https://example.com/github/webhook",
"active": false
},
"redirect_url": "https://github.com/settings/apps/new",
"callback_urls": [],
"setup_url": "https://github.com/anthropics/claude-code-action/blob/main/docs/setup.md",
"public": false,
"default_permissions": {
"contents": "write",
"issues": "write",
"pull_requests": "write",
"actions": "read",
"metadata": "read"
},
"default_events": [
"issue_comment",
"issues",
"pull_request",
"pull_request_review",
"pull_request_review_comment"
]
}

View File

@@ -384,7 +384,6 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
};
case "pull_request":
case "pull_request_target":
return {
eventType: "PULL_REQUEST",
triggerContext: eventData.eventAction
@@ -684,7 +683,7 @@ ${
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
- Your comment must always include the job run link in the format "[View job run](${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${process.env.GITHUB_RUN_ID})" at the bottom of your response (branch link if there is one should also be included there).
- Your comment must always include the job run link (and branch link if there is one) at the bottom.
CAPABILITIES AND LIMITATIONS:
When users ask you to do something, be aware of what you can and cannot do. This section helps you understand how to respond when users request actions outside your scope.
@@ -709,7 +708,7 @@ What You CANNOT Do:
- Modify files in the .github/workflows directory (GitHub App permissions do not allow workflow modifications)
When users ask you to perform actions you cannot do, politely explain the limitation and, when applicable, direct them to the FAQ for more information and workarounds:
"I'm unable to [specific action] due to [reason]. You can find more information and potential workarounds in the [FAQ](https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md)."
"I'm unable to [specific action] due to [reason]. You can find more information and potential workarounds in the [FAQ](https://github.com/anthropics/claude-code-action/blob/main/FAQ.md)."
If a user asks for something outside these capabilities (and you have no other tools provided), politely explain that you cannot perform that action and suggest an alternative approach if possible.

View File

@@ -78,7 +78,8 @@ type IssueLabeledEvent = {
labelTrigger: string;
};
type PullRequestBaseEvent = {
type PullRequestEvent = {
eventName: "pull_request";
eventAction?: string; // opened, synchronize, etc.
isPR: true;
prNumber: string;
@@ -86,14 +87,6 @@ type PullRequestBaseEvent = {
baseBranch?: string;
};
type PullRequestEvent = PullRequestBaseEvent & {
eventName: "pull_request";
};
type PullRequestTargetEvent = PullRequestBaseEvent & {
eventName: "pull_request_target";
};
// Union type for all possible event types
export type EventData =
| PullRequestReviewCommentEvent
@@ -103,8 +96,7 @@ export type EventData =
| IssueOpenedEvent
| IssueAssignedEvent
| IssueLabeledEvent
| PullRequestEvent
| PullRequestTargetEvent;
| PullRequestEvent;
// Combined type with separate eventData field
export type PreparedContext = CommonFields & {

View File

@@ -16,6 +16,11 @@ export const PR_QUERY = `
additions
deletions
state
labels(first: 1) {
nodes {
name
}
}
commits(first: 100) {
totalCount
nodes {
@@ -97,6 +102,11 @@ export const ISSUE_QUERY = `
}
createdAt
state
labels(first: 1) {
nodes {
name
}
}
comments(first: 100) {
nodes {
id

View File

@@ -88,6 +88,7 @@ type BaseContext = {
labelTrigger: string;
baseBranch?: string;
branchPrefix: string;
branchNameTemplate?: string;
useStickyComment: boolean;
useCommitSigning: boolean;
botId: string;
@@ -143,6 +144,7 @@ export function parseGitHubContext(): GitHubContext {
labelTrigger: process.env.LABEL_TRIGGER ?? "",
baseBranch: process.env.BASE_BRANCH,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
@@ -174,8 +176,7 @@ export function parseGitHubContext(): GitHubContext {
isPR: Boolean(payload.issue.pull_request),
};
}
case "pull_request":
case "pull_request_target": {
case "pull_request": {
const payload = context.payload as PullRequestEvent;
return {
...commonFields,

View File

@@ -12,6 +12,15 @@ import type { ParsedGitHubContext } from "../context";
import type { GitHubPullRequest } from "../types";
import type { Octokits } from "../api/client";
import type { FetchDataResult } from "../data/fetcher";
import { generateBranchName } from "../../utils/branch-template";
/**
* Extracts the first label from GitHub data, or returns undefined if no labels exist
*/
function extractFirstLabel(githubData: FetchDataResult): string | undefined {
const labels = githubData.contextData.labels?.nodes;
return labels && labels.length > 0 ? labels[0]?.name : undefined;
}
export type BranchInfo = {
baseBranch: string;
@@ -26,7 +35,7 @@ export async function setupBranch(
): Promise<BranchInfo> {
const { owner, repo } = context.repository;
const entityNumber = context.entityNumber;
const { baseBranch, branchPrefix } = context.inputs;
const { baseBranch, branchPrefix, branchNameTemplate } = context.inputs;
const isPR = context.isPR;
if (isPR) {
@@ -87,17 +96,8 @@ export async function setupBranch(
// Generate branch name for either an issue or closed/merged PR
const entityType = isPR ? "pr" : "issue";
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
const now = new Date();
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
// Ensure branch name is Kubernetes-compatible:
// - Lowercase only
// - Alphanumeric with hyphens
// - No underscores
// - Max 50 chars (to allow for prefixes)
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
const newBranch = branchName.toLowerCase().substring(0, 50);
// Get the SHA of the source branch to use in template
let sourceSHA: string | undefined;
try {
// Get the SHA of the source branch to verify it exists
@@ -107,8 +107,46 @@ export async function setupBranch(
ref: `heads/${sourceBranch}`,
});
const currentSHA = sourceBranchRef.data.object.sha;
console.log(`Source branch SHA: ${currentSHA}`);
sourceSHA = sourceBranchRef.data.object.sha;
console.log(`Source branch SHA: ${sourceSHA}`);
// Extract first label from GitHub data
const firstLabel = extractFirstLabel(githubData);
// Extract title from GitHub data
const title = githubData.contextData.title;
// Generate branch name using template or default format
let newBranch = generateBranchName(
branchNameTemplate,
branchPrefix,
entityType,
entityNumber,
sourceSHA,
firstLabel,
title,
);
// Check if generated branch already exists on remote
try {
await $`git ls-remote --exit-code origin refs/heads/${newBranch}`.quiet();
// If we get here, branch exists (exit code 0)
console.log(
`Branch '${newBranch}' already exists, falling back to default format`,
);
newBranch = generateBranchName(
undefined, // Force default template
branchPrefix,
entityType,
entityNumber,
sourceSHA,
firstLabel,
title,
);
} catch {
// Branch doesn't exist (non-zero exit code), continue with generated name
}
// For commit signing, defer branch creation to the file ops server
if (context.inputs.useCommitSigning) {

View File

@@ -61,6 +61,11 @@ export type GitHubPullRequest = {
additions: number;
deletions: number;
state: string;
labels: {
nodes: Array<{
name: string;
}>;
};
commits: {
totalCount: number;
nodes: Array<{
@@ -84,6 +89,11 @@ export type GitHubIssue = {
author: GitHubAuthor;
createdAt: string;
state: string;
labels: {
nodes: Array<{
name: string;
}>;
};
comments: {
nodes: GitHubComment[];
};

View File

@@ -209,7 +209,7 @@ export async function prepareMcpConfig(
"GITHUB_PERSONAL_ACCESS_TOKEN",
"-e",
"GITHUB_HOST",
"ghcr.io/github/github-mcp-server:sha-23fa0dd", // https://github.com/github/github-mcp-server/releases/tag/v0.17.1
"ghcr.io/github/github-mcp-server:sha-efef8ae", // https://github.com/github/github-mcp-server/releases/tag/v0.9.0
],
env: {
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,

View File

@@ -1,10 +1,10 @@
export function parseAllowedTools(claudeArgs: string): string[] {
// Match --allowedTools or --allowed-tools followed by the value
// Match --allowedTools followed by the value
// Handle both quoted and unquoted values
const patterns = [
/--(?:allowedTools|allowed-tools)\s+"([^"]+)"/, // Double quoted
/--(?:allowedTools|allowed-tools)\s+'([^']+)'/, // Single quoted
/--(?:allowedTools|allowed-tools)\s+([^\s]+)/, // Unquoted
/--allowedTools\s+"([^"]+)"/, // Double quoted
/--allowedTools\s+'([^']+)'/, // Single quoted
/--allowedTools\s+([^\s]+)/, // Unquoted
];
for (const pattern of patterns) {

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bun
/**
* Branch name template parsing and variable substitution utilities
*/
/**
* Extracts the first three words from a title and converts them to kebab-case
*/
function extractDescription(title: string): string {
if (!title || title.trim() === "") {
return "";
}
return title
.trim() // Remove leading/trailing whitespace
.split(/\s+/) // Split on whitespace
.slice(0, 3) // Take first 3 words
.join("-") // Join with hyphens
.toLowerCase() // Convert to lowercase
.replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens
.replace(/-+/g, "-") // Replace multiple hyphens with single
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
}
export interface BranchTemplateVariables {
prefix: string;
entityType: string;
entityNumber: number;
timestamp: string;
sha?: string;
label?: string;
description?: string;
}
/**
* Replaces template variables in a branch name template
* Template format: {{variableName}}
*/
export function applyBranchTemplate(
template: string,
variables: BranchTemplateVariables,
): string {
let result = template;
// Replace each variable
Object.entries(variables).forEach(([key, value]) => {
const placeholder = `{{${key}}}`;
const replacement = value ? String(value) : "";
result = result.replaceAll(placeholder, replacement);
});
return result;
}
/**
* Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result.
*/
export function generateBranchName(
template: string | undefined,
branchPrefix: string,
entityType: string,
entityNumber: number,
sha?: string,
label?: string,
title?: string,
): string {
const now = new Date();
const variables: BranchTemplateVariables = {
prefix: branchPrefix,
entityType,
entityNumber,
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
sha: sha?.substring(0, 8), // First 8 characters of SHA
label: label || entityType, // Fall back to entityType if no label
description: title ? extractDescription(title) : undefined,
};
if (template?.trim()) {
const branchName = applyBranchTemplate(template, variables);
// Some templates could produce empty results- validate
if (branchName.trim().length > 0) return branchName;
console.log(
`Branch template '${template}' generated empty result, falling back to default format`,
);
}
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`;
// Kubernetes compatible: lowercase, max 50 chars, alphanumeric and hyphens only
return branchName.toLowerCase().substring(0, 50);
}

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env bun
import { describe, it, expect } from "bun:test";
import {
applyBranchTemplate,
generateBranchName,
} from "../src/utils/branch-template";
describe("branch template utilities", () => {
describe("applyBranchTemplate", () => {
it("should replace all template variables", () => {
const template =
"{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}";
const variables = {
prefix: "feat/",
entityType: "issue",
entityNumber: 123,
timestamp: "20240301-1430",
sha: "abcd1234",
};
const result = applyBranchTemplate(template, variables);
expect(result).toBe("feat/issue-123-20240301-1430");
});
it("should handle custom templates with multiple variables", () => {
const template =
"{{prefix}}fix/{{entityType}}_{{entityNumber}}_{{timestamp}}_{{sha}}";
const variables = {
prefix: "claude-",
entityType: "pr",
entityNumber: 456,
timestamp: "20240301-1430",
sha: "abcd1234",
};
const result = applyBranchTemplate(template, variables);
expect(result).toBe("claude-fix/pr_456_20240301-1430_abcd1234");
});
it("should handle templates with missing variables gracefully", () => {
const template = "{{prefix}}{{entityType}}-{{missing}}-{{entityNumber}}";
const variables = {
prefix: "feat/",
entityType: "issue",
entityNumber: 123,
timestamp: "20240301-1430",
};
const result = applyBranchTemplate(template, variables);
expect(result).toBe("feat/issue-{{missing}}-123");
});
});
describe("generateBranchName", () => {
it("should use custom template when provided", () => {
const template = "{{prefix}}custom-{{entityType}}_{{entityNumber}}";
const result = generateBranchName(template, "feature/", "issue", 123);
expect(result).toBe("feature/custom-issue_123");
});
it("should use default format when template is empty", () => {
const result = generateBranchName("", "claude/", "issue", 123);
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
});
it("should use default format when template is undefined", () => {
const result = generateBranchName(undefined, "claude/", "pr", 456);
expect(result).toMatch(/^claude\/pr-456-\d{8}-\d{4}$/);
});
it("should preserve custom template formatting (no automatic lowercase/truncation)", () => {
const template = "{{prefix}}UPPERCASE_Branch-Name_{{entityNumber}}";
const result = generateBranchName(template, "Feature/", "issue", 123);
expect(result).toBe("Feature/UPPERCASE_Branch-Name_123");
});
it("should not truncate custom template results", () => {
const template =
"{{prefix}}very-long-branch-name-that-exceeds-the-maximum-allowed-length-{{entityNumber}}";
const result = generateBranchName(template, "feature/", "issue", 123);
expect(result).toBe(
"feature/very-long-branch-name-that-exceeds-the-maximum-allowed-length-123",
);
});
it("should apply Kubernetes-compatible transformations to default template only", () => {
const result = generateBranchName(undefined, "Feature/", "issue", 123);
expect(result).toMatch(/^feature\/issue-123-\d{8}-\d{4}$/);
expect(result.length).toBeLessThanOrEqual(50);
});
it("should handle SHA in template", () => {
const template = "{{prefix}}{{entityType}}-{{entityNumber}}-{{sha}}";
const result = generateBranchName(
template,
"fix/",
"pr",
789,
"abcdef123456",
);
expect(result).toBe("fix/pr-789-abcdef12");
});
it("should use label in template when provided", () => {
const template = "{{prefix}}{{label}}/{{entityNumber}}";
const result = generateBranchName(
template,
"feature/",
"issue",
123,
undefined,
"bug",
);
expect(result).toBe("feature/bug/123");
});
it("should fallback to entityType when label template is used but no label provided", () => {
const template = "{{prefix}}{{label}}-{{entityNumber}}";
const result = generateBranchName(template, "fix/", "pr", 456);
expect(result).toBe("fix/pr-456");
});
it("should handle template with both label and entityType", () => {
const template = "{{prefix}}{{label}}-{{entityType}}_{{entityNumber}}";
const result = generateBranchName(
template,
"dev/",
"issue",
789,
undefined,
"enhancement",
);
expect(result).toBe("dev/enhancement-issue_789");
});
it("should use description in template when provided", () => {
const template = "{{prefix}}{{description}}/{{entityNumber}}";
const result = generateBranchName(
template,
"feature/",
"issue",
123,
undefined,
undefined,
"Fix login bug with OAuth",
);
expect(result).toBe("feature/fix-login-bug/123");
});
it("should handle template with multiple variables including description", () => {
const template =
"{{prefix}}{{label}}/{{description}}-{{entityType}}_{{entityNumber}}";
const result = generateBranchName(
template,
"dev/",
"issue",
456,
undefined,
"bug",
"User authentication fails completely",
);
expect(result).toBe("dev/bug/user-authentication-fails-issue_456");
});
it("should handle description with special characters in template", () => {
const template = "{{prefix}}{{description}}-{{entityNumber}}";
const result = generateBranchName(
template,
"fix/",
"pr",
789,
undefined,
undefined,
"Add: User Registration & Email Validation",
);
expect(result).toBe("fix/add-user-registration-789");
});
it("should handle empty description in template", () => {
const template = "{{prefix}}{{description}}-{{entityNumber}}";
const result = generateBranchName(
template,
"test/",
"issue",
101,
undefined,
undefined,
"",
);
expect(result).toBe("test/-101");
});
it("should fallback to default format when template produces empty result", () => {
const template = "{{description}}"; // Will be empty if no title provided
const result = generateBranchName(template, "claude/", "issue", 123);
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
expect(result.length).toBeLessThanOrEqual(50);
});
it("should fallback to default format when template produces only whitespace", () => {
const template = " {{description}} "; // Will be " " if description is empty
const result = generateBranchName(
template,
"fix/",
"pr",
456,
undefined,
undefined,
"",
);
expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/);
expect(result.length).toBeLessThanOrEqual(50);
});
});
});

View File

@@ -61,6 +61,7 @@ describe("generatePrompt", () => {
body: "This is a test PR",
author: { login: "testuser" },
state: "OPEN",
labels: { nodes: [] },
createdAt: "2023-01-01T00:00:00Z",
additions: 15,
deletions: 5,
@@ -475,6 +476,7 @@ describe("generatePrompt", () => {
body: "The login form is not working",
author: { login: "testuser" },
state: "OPEN",
labels: { nodes: [] },
createdAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [],

View File

@@ -28,6 +28,9 @@ describe("formatContext", () => {
additions: 50,
deletions: 30,
state: "OPEN",
labels: {
nodes: [],
},
commits: {
totalCount: 3,
nodes: [],
@@ -63,6 +66,9 @@ Changed Files: 2 files`,
author: { login: "test-user" },
createdAt: "2023-01-01T00:00:00Z",
state: "OPEN",
labels: {
nodes: [],
},
comments: {
nodes: [],
},

View File

@@ -68,20 +68,4 @@ describe("parseAllowedTools", () => {
"mcp__github_comment__update",
]);
});
test("parses kebab-case --allowed-tools", () => {
const args = "--allowed-tools mcp__github__*,mcp__github_comment__*";
expect(parseAllowedTools(args)).toEqual([
"mcp__github__*",
"mcp__github_comment__*",
]);
});
test("parses quoted kebab-case --allowed-tools", () => {
const args = '--allowed-tools "mcp__github__*,mcp__github_comment__*"';
expect(parseAllowedTools(args)).toEqual([
"mcp__github__*",
"mcp__github_comment__*",
]);
});
});

View File

@@ -1,504 +0,0 @@
#!/usr/bin/env bun
import { describe, test, expect } from "bun:test";
import {
getEventTypeAndContext,
generatePrompt,
generateDefaultPrompt,
} from "../src/create-prompt";
import type { PreparedContext } from "../src/create-prompt";
import type { Mode } from "../src/modes/types";
describe("pull_request_target event support", () => {
// Mock tag mode for testing
const mockTagMode: Mode = {
name: "tag",
description: "Tag mode",
shouldTrigger: () => true,
prepareContext: (context) => ({ mode: "tag", githubContext: context }),
getAllowedTools: () => [],
getDisallowedTools: () => [],
shouldCreateTrackingComment: () => true,
generatePrompt: (context, githubData, useCommitSigning) =>
generateDefaultPrompt(context, githubData, useCommitSigning),
prepare: async () => ({
commentId: 123,
branchInfo: {
baseBranch: "main",
currentBranch: "main",
claudeBranch: undefined,
},
mcpConfig: "{}",
}),
};
const mockGitHubData = {
contextData: {
title: "External PR via pull_request_target",
body: "This PR comes from a forked repository",
author: { login: "external-contributor" },
state: "OPEN",
createdAt: "2023-01-01T00:00:00Z",
additions: 25,
deletions: 3,
baseRefName: "main",
headRefName: "feature-branch",
headRefOid: "abc123",
commits: {
totalCount: 2,
nodes: [
{
commit: {
oid: "commit1",
message: "Initial feature implementation",
author: {
name: "External Dev",
email: "external@example.com",
},
},
},
{
commit: {
oid: "commit2",
message: "Fix typos and formatting",
author: {
name: "External Dev",
email: "external@example.com",
},
},
},
],
},
files: {
nodes: [
{
path: "src/feature.ts",
additions: 20,
deletions: 2,
changeType: "MODIFIED",
},
{
path: "tests/feature.test.ts",
additions: 5,
deletions: 1,
changeType: "ADDED",
},
],
},
comments: { nodes: [] },
reviews: { nodes: [] },
},
comments: [],
changedFiles: [],
changedFilesWithSHA: [
{
path: "src/feature.ts",
additions: 20,
deletions: 2,
changeType: "MODIFIED",
sha: "abc123",
},
{
path: "tests/feature.test.ts",
additions: 5,
deletions: 1,
changeType: "ADDED",
sha: "abc123",
},
],
reviewData: { nodes: [] },
imageUrlMap: new Map<string, string>(),
};
describe("prompt generation for pull_request_target", () => {
test("should generate correct prompt for pull_request_target event", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_target",
eventAction: "opened",
isPR: true,
prNumber: "123",
},
};
const prompt = generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain pull request event type and metadata
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>");
expect(prompt).toContain("<pr_number>123</pr_number>");
expect(prompt).toContain(
"<trigger_context>pull request opened</trigger_context>",
);
// Should contain PR-specific information
expect(prompt).toContain(
"- src/feature.ts (MODIFIED) +20/-2 SHA: abc123",
);
expect(prompt).toContain(
"- tests/feature.test.ts (ADDED) +5/-1 SHA: abc123",
);
expect(prompt).toContain("external-contributor");
expect(prompt).toContain("<repository>owner/repo</repository>");
});
test("should handle pull_request_target with commit signing disabled", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_target",
eventAction: "synchronize",
isPR: true,
prNumber: "456",
},
};
const prompt = generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should include git commands for non-commit-signing mode
expect(prompt).toContain("git push");
expect(prompt).toContain(
"Always push to the existing branch when triggered on a PR",
);
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
// Should not include commit signing tools
expect(prompt).not.toContain("mcp__github_file_ops__commit_files");
});
test("should handle pull_request_target with commit signing enabled", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_target",
eventAction: "synchronize",
isPR: true,
prNumber: "456",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, true, mockTagMode);
// Should include commit signing tools
expect(prompt).toContain("mcp__github_file_ops__commit_files");
expect(prompt).toContain("mcp__github_file_ops__delete_files");
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
// Should not include git command instructions
expect(prompt).not.toContain("Use git commands via the Bash tool");
});
test("should treat pull_request_target same as pull_request in prompt generation", () => {
const baseContext: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_target",
eventAction: "opened",
isPR: true,
prNumber: "123",
},
};
// Generate prompt for pull_request
const pullRequestContext: PreparedContext = {
...baseContext,
eventData: {
...baseContext.eventData,
eventName: "pull_request",
isPR: true,
prNumber: "123",
},
};
// Generate prompt for pull_request_target
const pullRequestTargetContext: PreparedContext = {
...baseContext,
eventData: {
...baseContext.eventData,
eventName: "pull_request_target",
isPR: true,
prNumber: "123",
},
};
const pullRequestPrompt = generatePrompt(
pullRequestContext,
mockGitHubData,
false,
mockTagMode,
);
const pullRequestTargetPrompt = generatePrompt(
pullRequestTargetContext,
mockGitHubData,
false,
mockTagMode,
);
// Both should have the same event type and structure
expect(pullRequestPrompt).toContain(
"<event_type>PULL_REQUEST</event_type>",
);
expect(pullRequestTargetPrompt).toContain(
"<event_type>PULL_REQUEST</event_type>",
);
expect(pullRequestPrompt).toContain(
"<trigger_context>pull request opened</trigger_context>",
);
expect(pullRequestTargetPrompt).toContain(
"<trigger_context>pull request opened</trigger_context>",
);
// Both should contain PR-specific instructions
expect(pullRequestPrompt).toContain(
"Always push to the existing branch when triggered on a PR",
);
expect(pullRequestTargetPrompt).toContain(
"Always push to the existing branch when triggered on a PR",
);
});
test("should handle pull_request_target in agent mode with custom prompt", () => {
const envVars: PreparedContext = {
repository: "test/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
prompt: "Review this pull_request_target PR for security issues",
eventData: {
eventName: "pull_request_target",
eventAction: "opened",
isPR: true,
prNumber: "789",
},
};
// Use agent mode which passes through the prompt as-is
const mockAgentMode: Mode = {
name: "agent",
description: "Agent mode",
shouldTrigger: () => true,
prepareContext: (context) => ({
mode: "agent",
githubContext: context,
}),
getAllowedTools: () => [],
getDisallowedTools: () => [],
shouldCreateTrackingComment: () => true,
generatePrompt: (context) => context.prompt || "default prompt",
prepare: async () => ({
commentId: 123,
branchInfo: {
baseBranch: "main",
currentBranch: "main",
claudeBranch: undefined,
},
mcpConfig: "{}",
}),
};
const prompt = generatePrompt(
envVars,
mockGitHubData,
false,
mockAgentMode,
);
expect(prompt).toBe(
"Review this pull_request_target PR for security issues",
);
});
test("should handle pull_request_target with no custom prompt", () => {
const envVars: PreparedContext = {
repository: "test/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_target",
eventAction: "synchronize",
isPR: true,
prNumber: "456",
},
};
const prompt = generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should generate default prompt structure
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
expect(prompt).toContain("<pr_number>456</pr_number>");
expect(prompt).toContain(
"Always push to the existing branch when triggered on a PR",
);
});
});
describe("pull_request_target vs pull_request behavior consistency", () => {
test("should produce identical event processing for both event types", () => {
const baseEventData = {
eventAction: "opened",
isPR: true,
prNumber: "100",
};
const pullRequestEvent: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
...baseEventData,
eventName: "pull_request",
isPR: true,
prNumber: "100",
},
};
const pullRequestTargetEvent: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
...baseEventData,
eventName: "pull_request_target",
isPR: true,
prNumber: "100",
},
};
// Both should have identical event type detection
const prResult = getEventTypeAndContext(pullRequestEvent);
const prtResult = getEventTypeAndContext(pullRequestTargetEvent);
expect(prResult.eventType).toBe(prtResult.eventType);
expect(prResult.triggerContext).toBe(prtResult.triggerContext);
});
test("should handle edge cases in pull_request_target events", () => {
// Test with minimal event data
const minimalContext: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_target",
isPR: true,
prNumber: "1",
},
};
const result = getEventTypeAndContext(minimalContext);
expect(result.eventType).toBe("PULL_REQUEST");
expect(result.triggerContext).toBe("pull request event");
// Should not throw when generating prompt
expect(() => {
generatePrompt(minimalContext, mockGitHubData, false, mockTagMode);
}).not.toThrow();
});
test("should handle all valid pull_request_target actions", () => {
const actions = ["opened", "synchronize", "reopened", "closed", "edited"];
actions.forEach((action) => {
const context: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_target",
eventAction: action,
isPR: true,
prNumber: "1",
},
};
const result = getEventTypeAndContext(context);
expect(result.eventType).toBe("PULL_REQUEST");
expect(result.triggerContext).toBe(`pull request ${action}`);
});
});
});
describe("security considerations for pull_request_target", () => {
test("should maintain same prompt structure regardless of event source", () => {
// Test that external PRs don't get different treatment in prompts
const internalPR: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request",
eventAction: "opened",
isPR: true,
prNumber: "1",
},
};
const externalPR: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "pull_request_target",
eventAction: "opened",
isPR: true,
prNumber: "1",
},
};
const internalPrompt = generatePrompt(
internalPR,
mockGitHubData,
false,
mockTagMode,
);
const externalPrompt = generatePrompt(
externalPR,
mockGitHubData,
false,
mockTagMode,
);
// Should have same tool access patterns
expect(
internalPrompt.includes("mcp__github_comment__update_claude_comment"),
).toBe(
externalPrompt.includes("mcp__github_comment__update_claude_comment"),
);
// Should have same branch handling instructions
expect(
internalPrompt.includes(
"Always push to the existing branch when triggered on a PR",
),
).toBe(
externalPrompt.includes(
"Always push to the existing branch when triggered on a PR",
),
);
});
});
});