Compare commits

..

25 Commits

Author SHA1 Message Date
inigo
84265a4271 update 2025-11-18 14:37:48 -08:00
inigo
9d3bab5bc7 test: add proper test coverage for parseAndSetStructuredOutputs
Fixed test coverage gap where tests were only parsing JSON manually
without actually invoking the parseAndSetStructuredOutputs function.

Changes:
- Export parseAndSetStructuredOutputs for testing
- Rewrite tests to use spyOn() to mock @actions/core functions
- Add tests that actually call the function and verify:
  - core.setOutput() called with correct JSON string
  - core.info() called with correct field count
  - Error thrown when result exists but structured_output undefined
  - Error thrown when no result message exists
  - Handles special characters in field names (hyphens, dots, @ symbols)
  - Handles arrays and nested objects correctly
  - File errors propagate correctly

All 8 tests now properly test the actual implementation with full
coverage of success and error paths.

Addresses review comment: https://github.com/anthropics/claude-code-action/pull/683#discussion_r2539770213

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 14:26:53 -08:00
inigo
bf8f85ca9d docs: fix incorrect field naming restrictions in base-action/action.yml
Fixed outdated documentation that incorrectly stated field naming
restrictions that don't exist in the implementation.

Changes:
- Removed incorrect claim about field naming requirements (letter/underscore start)
- Removed incorrect claim about special character sanitization
- Clarified that field names can use any valid JSON property name
- Updated access pattern to show fromJSON() usage
- Clarified 1MB limit applies to entire structured_output string, not per-field

The implementation simply does JSON.stringify(result.structured_output)
without any sanitization, so any valid JSON property name works (including
hyphens like "test-result", as validated by integration tests).

Addresses review comment: https://github.com/anthropics/claude-code-action/pull/683#discussion_r2539749593

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 14:22:44 -08:00
inigo
f551cdf070 fix: remove double error reporting in parseAndSetStructuredOutputs
Fixed error handling anti-pattern identified in PR review where the
function was calling core.setFailed() AND throwing errors, causing
confusion about error handling flow.

Changes:
- parseAndSetStructuredOutputs now just throws errors without calling
  core.setFailed() - follows single responsibility principle
- Caller (runClaude) catches errors and calls core.setFailed() once
- Removed unnecessary structuredOutputSuccess boolean flag
- Clearer error handling flow: function parses/throws, caller decides
  how to handle failures

Addresses review comment: https://github.com/anthropics/claude-code-action/pull/683#discussion_r2539741001

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 14:13:57 -08:00
inigo
ec3a934da7 docs: update structured output documentation for JSON-only approach
Updated documentation to reflect that structured outputs are now only
accessible via the single structured_output JSON string, not as
individual fields.

Changes:
- docs/usage.md: Updated "Accessing Structured Outputs" section
  - Show fromJSON() usage in GitHub Actions expressions
  - Show jq usage in bash
  - Explain composite action limitation
  - Remove outdated "Output Naming Rules" and size limit sections
- action.yml: Updated json_schema input description
- examples/test-failure-analysis.yml: Updated to use fromJSON() and jq

Users now access fields via:
  fromJSON(steps.<id>.outputs.structured_output).field_name
Or:
  echo '${{ steps.<id>.outputs.structured_output }}' | jq '.field_name'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 14:07:00 -08:00
inigo
8cd2cc1236 refactor: remove individual field outputs, keep only structured_output JSON
Since GitHub Actions composite actions cannot expose dynamic outputs,
individual field outputs were not accessible anyway and only added
complexity and collision risk.

Simplified by:
- Removing individual core.setOutput() calls for each field
- Removing RESERVED_OUTPUTS check (no longer needed)
- Removing sanitizeOutputName, convertToString, MAX_OUTPUT_SIZE helpers
- Removing related unit tests for removed functionality

Users access all fields via single structured_output JSON string:
  fromJSON(steps.<id>.outputs.structured_output).field_name

Or with jq:
  echo '${{ steps.<id>.outputs.structured_output }}' | jq '.field_name'

All tests pass (462 tests).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 14:01:58 -08:00
inigo
dcee434ef2 fix: workaround GitHub Actions composite action output limitation
GitHub Actions composite actions cannot have dynamic outputs - all outputs
must be explicitly declared in action.yml. This is a known limitation.

Changes:
- Add structured_output JSON output to base-action/action.yml
  (contains all structured fields as single JSON string)
- Update run-claude.ts to set structured_output output
- Update tests to parse structured_output JSON with jq
- Add structured_output to RESERVED_OUTPUTS list

Users can now access structured outputs via:
  steps.<id>.outputs.structured_output | jq '.field_name'

Or in GitHub Actions expressions:
  fromJSON(steps.<id>.outputs.structured_output).field_name

Individual field outputs are still set for direct usage contexts,
but only the structured_output JSON is accessible via composite action.

Fixes #683 test failures

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 12:08:41 -08:00
inigo
e93583852d fix: address PR #683 review feedback
Critical fixes:
- Remove duplicate core.setFailed() call in parseAndSetStructuredOutputs
  (fixes double error reporting issue)
- Extract JSON schema handling to shared utility function
  (eliminates code duplication between agent/tag modes)

Changes:
- base-action/src/run-claude.ts: Remove redundant setFailed() before throw
- src/utils/json-schema.ts: New shared appendJsonSchemaArg() utility
- src/modes/agent/index.ts: Use shared JSON schema utility
- src/modes/tag/index.ts: Use shared JSON schema utility

All tests passing, types checked, code formatted.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 11:55:41 -08:00
inigo
e600a516c7 feat: add structured output support
Add support for Agent SDK structured outputs.

New input: json_schema - JSON schema for validated outputs
Auto-sets GitHub Action outputs for each field

Security:
- Reserved output protection (prevents shadowing)
- 1MB output size limits enforced
- Output key format validation
- Objects/arrays >1MB skipped (not truncated to invalid JSON)

Tests:
- 26 unit tests
- 5 integration tests
- 480 tests passing

Docs: https://docs.claude.com/en/docs/agent-sdk/structured-outputs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 11:48:03 -08:00
GitHub Actions
08f88abe2b chore: bump Claude Code version to 2.0.42 2025-11-15 00:17:35 +00:00
GitHub Actions
14ab4250bb chore: bump Claude Code version to 2.0.37 2025-11-11 00:21:46 +00:00
GitHub Actions
c7fdd19642 chore: bump Claude Code version to 2.0.36 2025-11-07 22:08:15 +00:00
GitHub Actions
92d173475f chore: bump Claude Code version to 2.0.35 2025-11-06 21:07:07 +00:00
GitHub Actions
108e982900 chore: bump Claude Code version to 2.0.34 2025-11-05 21:11:28 +00:00
GitHub Actions
7bb53ae6ee chore: bump Claude Code version to 2.0.33 2025-11-04 23:40:50 +00:00
GitHub Actions
804b418b93 chore: bump Claude Code version to 2.0.32 2025-11-03 23:22:17 +00:00
GitHub Actions
500439cb9b chore: bump Claude Code version to 2.0.31 2025-10-31 22:00:23 +00:00
GitHub Actions
4cda0ef6d1 chore: bump Claude Code version to 2.0.30 2025-10-30 23:35:37 +00:00
Ashwin Bhat
037b85d0d2 docs: update action version from @beta to @v1 in docs (#650)
Updates documentation examples to use @v1 instead of @beta in:
- docs/setup.md: custom GitHub app example
- docs/configuration.md: additional permissions examples

Migration guide and usage comparison examples intentionally kept with @beta to show old syntax.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-29 21:45:52 -07:00
GitHub Actions
8a1c437175 chore: bump Claude Code version to 2.0.29 2025-10-29 23:25:55 +00:00
David Dworken
56c8ae7d88 Add show_full_output option to control output verbosity (#580)
* Add show_full_output option to control output verbosity

* Update base-action/src/run-claude.ts

Co-authored-by: Ashwin Bhat <ashwin@anthropic.com>

* Wire show_full_output through to base-action

* Document show_full_output security warnings in docs/security.md

---------

Co-authored-by: Ashwin Bhat <ashwin@anthropic.com>
2025-10-28 11:52:18 -07:00
GitHub Actions
f4d737af0b chore: bump Claude Code version to 2.0.28 2025-10-27 21:32:34 +00:00
Wanghong Yuan
29fe50368c feat: change plugins input from comma-separated to newline-separated (#644)
* feat: change plugins input from comma-separated to newline-separated

Changes:
- Update parsePlugins() to split by newline instead of comma for consistency with marketplaces input
- Update action.yml and base-action/action.yml with newline-separated format and realistic plugin examples
- Add plugin_marketplaces documentation to docs/usage.md
- Update all unit tests to match new installPlugins() signature (marketplaces, plugins, executable)
- Improve JSDoc comments for parsePlugins() and installPlugin() functions
- All 25 install-plugins tests passing

Breaking change: Users must update their workflows to use newline-separated format:
  Before: plugins: "plugin1,plugin2"
  After: plugins: "plugin1\nplugin2"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* test: add comprehensive marketplace functionality tests

Critical fix: All previous tests passed undefined as marketplacesInput parameter,
leaving the entire marketplace functionality completely untested.

Added 13 new tests covering:
- Single marketplace installation
- Multiple marketplaces with newline separation
- Marketplace + plugin installation order verification
- Marketplace URL validation (format, protocol, .git extension)
- Whitespace and empty entry handling
- Error handling for marketplace operations
- Custom executable path for marketplace operations

Test coverage: 38 tests (was 25), 81 expect calls (was 50)
All tests passing 

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-27 09:01:34 -07:00
Kris Coleman
8ad13bd20b feat(docs): simplify custom GitHub App creation with manifest support (#620)
* feat(docs): simplify custom GitHub App creation with manifest support

- Add github-app-manifest.json with pre-configured permissions
- Create interactive HTML tool for one-click app creation
- Update setup.md documentation with manifest-based instructions
- Maintain existing manual setup as alternative option

This significantly improves the developer experience by eliminating
manual permission configuration and reducing setup time from multiple
steps to a single click.

Fixes #619

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Kris Coleman <kriscodeman@gmail.com>

* feat: create-app ux improvements

Signed-off-by: Kris Coleman <kriscodeman@gmail.com>

---------

Signed-off-by: Kris Coleman <kriscodeman@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-27 08:26:46 -07:00
Ashwin Bhat
7b914ae5c0 feat: add plugin_marketplaces input for dynamic marketplace installation (#642)
- Added plugin_marketplaces input to both main and base-action action.yml files
- Updated install-plugins.ts to support multiple marketplace URLs (newline-separated)
- Added validation for marketplace URLs to prevent security issues
- Updated installPlugins function to dynamically add marketplaces instead of hardcoding
- Defaults to official Claude Code marketplace when no marketplaces are specified
- Updated base-action index.ts to pass plugin_marketplaces to installPlugins

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-26 15:47:23 -07:00
28 changed files with 2443 additions and 366 deletions

View File

@@ -0,0 +1,349 @@
name: Test Structured Outputs
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test-basic-types:
name: Test Basic Type Conversions
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Test with explicit values
id: test
uses: ./base-action
with:
prompt: |
Run this command: echo "test"
Then return EXACTLY these values:
- text_field: "hello"
- number_field: 42
- boolean_true: true
- boolean_false: false
json_schema: |
{
"type": "object",
"properties": {
"text_field": {"type": "string"},
"number_field": {"type": "number"},
"boolean_true": {"type": "boolean"},
"boolean_false": {"type": "boolean"}
},
"required": ["text_field", "number_field", "boolean_true", "boolean_false"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
- name: Verify outputs
run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Test string pass-through
TEXT_FIELD=$(echo "$OUTPUT" | jq -r '.text_field')
if [ "$TEXT_FIELD" != "hello" ]; then
echo "❌ String: expected 'hello', got '$TEXT_FIELD'"
exit 1
fi
# Test number → string conversion
NUMBER_FIELD=$(echo "$OUTPUT" | jq -r '.number_field')
if [ "$NUMBER_FIELD" != "42" ]; then
echo "❌ Number: expected '42', got '$NUMBER_FIELD'"
exit 1
fi
# Test boolean → "true" conversion
BOOLEAN_TRUE=$(echo "$OUTPUT" | jq -r '.boolean_true')
if [ "$BOOLEAN_TRUE" != "true" ]; then
echo "❌ Boolean true: expected 'true', got '$BOOLEAN_TRUE'"
exit 1
fi
# Test boolean → "false" conversion
BOOLEAN_FALSE=$(echo "$OUTPUT" | jq -r '.boolean_false')
if [ "$BOOLEAN_FALSE" != "false" ]; then
echo "❌ Boolean false: expected 'false', got '$BOOLEAN_FALSE'"
exit 1
fi
echo "✅ All basic type conversions correct"
test-complex-types:
name: Test Arrays and Objects
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Test complex types
id: test
uses: ./base-action
with:
prompt: |
Run: echo "ready"
Return EXACTLY:
- items: ["apple", "banana", "cherry"]
- config: {"key": "value", "count": 3}
- empty_array: []
json_schema: |
{
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {"type": "string"}
},
"config": {"type": "object"},
"empty_array": {"type": "array"}
},
"required": ["items", "config", "empty_array"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
- name: Verify JSON stringification
run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Arrays should be JSON stringified
if ! echo "$OUTPUT" | jq -e '.items | length == 3' > /dev/null; then
echo "❌ Array not properly formatted"
echo "$OUTPUT" | jq '.items'
exit 1
fi
# Objects should be JSON stringified
if ! echo "$OUTPUT" | jq -e '.config.key == "value"' > /dev/null; then
echo "❌ Object not properly formatted"
echo "$OUTPUT" | jq '.config'
exit 1
fi
# Empty arrays should work
if ! echo "$OUTPUT" | jq -e '.empty_array | length == 0' > /dev/null; then
echo "❌ Empty array not properly formatted"
echo "$OUTPUT" | jq '.empty_array'
exit 1
fi
echo "✅ All complex types handled correctly"
test-edge-cases:
name: Test Edge Cases
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Test edge cases
id: test
uses: ./base-action
with:
prompt: |
Run: echo "test"
Return EXACTLY:
- zero: 0
- empty_string: ""
- negative: -5
- decimal: 3.14
json_schema: |
{
"type": "object",
"properties": {
"zero": {"type": "number"},
"empty_string": {"type": "string"},
"negative": {"type": "number"},
"decimal": {"type": "number"}
},
"required": ["zero", "empty_string", "negative", "decimal"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
- name: Verify edge cases
run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Zero should be "0", not empty or falsy
ZERO=$(echo "$OUTPUT" | jq -r '.zero')
if [ "$ZERO" != "0" ]; then
echo "❌ Zero: expected '0', got '$ZERO'"
exit 1
fi
# Empty string should be empty (not "null" or missing)
EMPTY_STRING=$(echo "$OUTPUT" | jq -r '.empty_string')
if [ "$EMPTY_STRING" != "" ]; then
echo "❌ Empty string: expected '', got '$EMPTY_STRING'"
exit 1
fi
# Negative numbers should work
NEGATIVE=$(echo "$OUTPUT" | jq -r '.negative')
if [ "$NEGATIVE" != "-5" ]; then
echo "❌ Negative: expected '-5', got '$NEGATIVE'"
exit 1
fi
# Decimals should preserve precision
DECIMAL=$(echo "$OUTPUT" | jq -r '.decimal')
if [ "$DECIMAL" != "3.14" ]; then
echo "❌ Decimal: expected '3.14', got '$DECIMAL'"
exit 1
fi
echo "✅ All edge cases handled correctly"
test-name-sanitization:
name: Test Output Name Sanitization
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Test special characters in field names
id: test
uses: ./base-action
with:
prompt: |
Run: echo "test"
Return EXACTLY: {test-result: "passed", item_count: 10}
json_schema: |
{
"type": "object",
"properties": {
"test-result": {"type": "string"},
"item_count": {"type": "number"}
},
"required": ["test-result", "item_count"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
- name: Verify sanitized names work
run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Hyphens should be preserved in the JSON
TEST_RESULT=$(echo "$OUTPUT" | jq -r '.["test-result"]')
if [ "$TEST_RESULT" != "passed" ]; then
echo "❌ Hyphenated name failed: expected 'passed', got '$TEST_RESULT'"
exit 1
fi
# Underscores should work
ITEM_COUNT=$(echo "$OUTPUT" | jq -r '.item_count')
if [ "$ITEM_COUNT" != "10" ]; then
echo "❌ Underscore name failed: expected '10', got '$ITEM_COUNT'"
exit 1
fi
echo "✅ Name sanitization works"
test-execution-file-structure:
name: Test Execution File Format
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Run with structured output
id: test
uses: ./base-action
with:
prompt: "Run: echo 'complete'. Return: {done: true}"
json_schema: |
{
"type": "object",
"properties": {
"done": {"type": "boolean"}
},
"required": ["done"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
- name: Verify execution file contains structured_output
run: |
FILE="${{ steps.test.outputs.execution_file }}"
# Check file exists
if [ ! -f "$FILE" ]; then
echo "❌ Execution file missing"
exit 1
fi
# Check for structured_output field
if ! jq -e '.[] | select(.type == "result") | .structured_output' "$FILE" > /dev/null; then
echo "❌ No structured_output in execution file"
cat "$FILE"
exit 1
fi
# Verify the actual value
DONE=$(jq -r '.[] | select(.type == "result") | .structured_output.done' "$FILE")
if [ "$DONE" != "true" ]; then
echo "❌ Wrong value in execution file"
exit 1
fi
echo "✅ Execution file format correct"
test-summary:
name: Summary
runs-on: ubuntu-latest
needs:
- test-basic-types
- test-complex-types
- test-edge-cases
- test-name-sanitization
- test-execution-file-structure
if: always()
steps:
- name: Generate Summary
run: |
echo "# Structured Output Tests (Optimized)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Fast, deterministic tests using explicit prompts" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Test | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Basic Types | ${{ needs.test-basic-types.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Complex Types | ${{ needs.test-complex-types.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Edge Cases | ${{ needs.test-edge-cases.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Name Sanitization | ${{ needs.test-name-sanitization.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Execution File | ${{ needs.test-execution-file-structure.result == 'success' && '✅ PASS' || '❌ FAIL' }} |" >> $GITHUB_STEP_SUMMARY
# Check if all passed
ALL_PASSED=${{
needs.test-basic-types.result == 'success' &&
needs.test-complex-types.result == 'success' &&
needs.test-edge-cases.result == 'success' &&
needs.test-name-sanitization.result == 'success' &&
needs.test-execution-file-structure.result == 'success'
}}
if [ "$ALL_PASSED" = "true" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "## ✅ All Tests Passed" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## ❌ Some Tests Failed" >> $GITHUB_STEP_SUMMARY
exit 1
fi

View File

@@ -13,6 +13,7 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
- 💬 **PR/Issue Integration**: Works seamlessly with GitHub comments and PR reviews - 💬 **PR/Issue Integration**: Works seamlessly with GitHub comments and PR reviews
- 🛠️ **Flexible Tool Access**: Access to GitHub APIs and file operations (additional tools can be enabled via configuration) - 🛠️ **Flexible Tool Access**: Access to GitHub APIs and file operations (additional tools can be enabled via configuration)
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks - 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
- 📊 **Structured Outputs**: Get validated JSON results that automatically become GitHub Action outputs for complex automations
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider) - 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
- ⚙️ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK - ⚙️ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK

View File

@@ -101,8 +101,20 @@ 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." 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 required: false
default: "" 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: plugins:
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')" 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: ""
json_schema:
description: "JSON schema for structured output validation. When provided, Claude will return validated JSON matching this schema. All fields are available in the structured_output output as a JSON string (use fromJSON() or jq to access fields)."
required: false required: false
default: "" default: ""
@@ -166,8 +178,8 @@ runs:
TRACK_PROGRESS: ${{ inputs.track_progress }} TRACK_PROGRESS: ${{ inputs.track_progress }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
CLAUDE_ARGS: ${{ inputs.claude_args }} CLAUDE_ARGS: ${{ inputs.claude_args }}
JSON_SCHEMA: ${{ inputs.json_schema }}
ALL_INPUTS: ${{ toJson(inputs) }} ALL_INPUTS: ${{ toJson(inputs) }}
PLUGINS: ${{ inputs.plugins }}
- name: Install Base Action Dependencies - name: Install Base Action Dependencies
if: steps.prepare.outputs.contains_trigger == 'true' if: steps.prepare.outputs.contains_trigger == 'true'
@@ -182,7 +194,7 @@ runs:
# Install Claude Code if no custom executable is provided # Install Claude Code if no custom executable is provided
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..." echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.27 curl -fsSL https://claude.ai/install.sh | bash -s 2.0.42
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
@@ -218,7 +230,10 @@ runs:
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }} 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_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }} INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGINS: ${{ inputs.plugins }}
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
JSON_SCHEMA: ${{ inputs.json_schema }}
# Model configuration # Model configuration
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}

View File

@@ -86,7 +86,7 @@ Add the following to your workflow file:
## Inputs ## Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
| ------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ---------------------------- | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------- |
| `prompt` | The prompt to send to Claude Code | No\* | '' | | `prompt` | The prompt to send to Claude Code | No\* | '' |
| `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' | | `prompt_file` | Path to a file containing the prompt to send to Claude Code | No\* | '' |
| `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' | | `allowed_tools` | Comma-separated list of allowed tools for Claude Code to use | No | '' |
@@ -105,9 +105,12 @@ Add the following to your workflow file:
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | 'false' | | `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_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' | | `use_node_cache` | Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files) | No | 'false' |
| `show_full_output` | Show full JSON output (⚠️ May expose secrets - see [security docs](../docs/security.md#-full-output-security-warning)) | No | 'false'\*\* |
\*Either `prompt` or `prompt_file` must be provided, but not both. \*Either `prompt` or `prompt_file` must be provided, but not both.
\*\*`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 ## Outputs
| Output | Description | | Output | Description |

View File

@@ -24,6 +24,10 @@ inputs:
description: "Additional arguments to pass directly to Claude CLI (e.g., '--max-turns 3 --mcp-config /path/to/config.json')" description: "Additional arguments to pass directly to Claude CLI (e.g., '--max-turns 3 --mcp-config /path/to/config.json')"
required: false required: false
default: "" default: ""
allowed_tools:
description: "Comma-separated list of allowed tools (e.g., 'Read,Write,Bash'). Passed as --allowedTools to Claude CLI"
required: false
default: ""
# Authentication settings # Authentication settings
anthropic_api_key: anthropic_api_key:
@@ -55,8 +59,24 @@ 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." 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 required: false
default: "" 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: plugins:
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')" 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: ""
json_schema:
description: |
JSON schema for structured output validation. Claude must return JSON matching this schema
or the action will fail. All fields are returned in a single structured_output JSON string.
Access outputs via: fromJSON(steps.<step-id>.outputs.structured_output).<field_name>
required: false required: false
default: "" default: ""
@@ -67,6 +87,9 @@ outputs:
execution_file: execution_file:
description: "Path to the JSON file containing Claude Code execution log" description: "Path to the JSON file containing Claude Code execution log"
value: ${{ steps.run_claude.outputs.execution_file }} value: ${{ steps.run_claude.outputs.execution_file }}
structured_output:
description: "JSON string containing all structured output fields (use fromJSON() or jq to parse)"
value: ${{ steps.run_claude.outputs.structured_output }}
runs: runs:
using: "composite" using: "composite"
@@ -103,7 +126,7 @@ runs:
run: | run: |
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..." echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.27 curl -fsSL https://claude.ai/install.sh | bash -s 2.0.45
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH
@@ -130,7 +153,11 @@ runs:
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }} INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }} INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }} INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
INPUT_PLUGINS: ${{ inputs.plugins }} INPUT_PLUGINS: ${{ inputs.plugins }}
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
INPUT_ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
JSON_SCHEMA: ${{ inputs.json_schema }}
# Provider configuration # Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}

196
base-action/package-lock.json generated Normal file
View File

@@ -0,0 +1,196 @@
{
"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

@@ -18,6 +18,7 @@ async function run() {
// Install Claude Code plugins if specified // Install Claude Code plugins if specified
await installPlugins( await installPlugins(
process.env.INPUT_PLUGIN_MARKETPLACES,
process.env.INPUT_PLUGINS, process.env.INPUT_PLUGINS,
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
); );
@@ -27,8 +28,22 @@ async function run() {
promptFile: process.env.INPUT_PROMPT_FILE || "", promptFile: process.env.INPUT_PROMPT_FILE || "",
}); });
// Build claudeArgs with JSON schema if provided
let claudeArgs = process.env.INPUT_CLAUDE_ARGS || "";
// Add allowed tools if specified
if (process.env.INPUT_ALLOWED_TOOLS) {
claudeArgs += ` --allowedTools "${process.env.INPUT_ALLOWED_TOOLS}"`;
}
// Add JSON schema if specified (no escaping - parseShellArgs handles it)
if (process.env.JSON_SCHEMA) {
// Wrap in single quotes for parseShellArgs
claudeArgs += ` --json-schema '${process.env.JSON_SCHEMA}'`;
}
await runClaude(promptConfig.path, { await runClaude(promptConfig.path, {
claudeArgs: process.env.INPUT_CLAUDE_ARGS, claudeArgs: claudeArgs.trim(),
allowedTools: process.env.INPUT_ALLOWED_TOOLS, allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS, maxTurns: process.env.INPUT_MAX_TURNS,
@@ -40,6 +55,7 @@ async function run() {
model: process.env.ANTHROPIC_MODEL, model: process.env.ANTHROPIC_MODEL,
pathToClaudeCodeExecutable: pathToClaudeCodeExecutable:
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE, process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
}); });
} catch (error) { } catch (error) {
core.setFailed(`Action failed with error: ${error}`); core.setFailed(`Action failed with error: ${error}`);

View File

@@ -2,10 +2,34 @@ import { spawn, ChildProcess } from "child_process";
const PLUGIN_NAME_REGEX = /^[@a-zA-Z0-9_\-\/\.]+$/; const PLUGIN_NAME_REGEX = /^[@a-zA-Z0-9_\-\/\.]+$/;
const MAX_PLUGIN_NAME_LENGTH = 512; const MAX_PLUGIN_NAME_LENGTH = 512;
const CLAUDE_CODE_MARKETPLACE_URL =
"https://github.com/anthropics/claude-code.git";
const PATH_TRAVERSAL_REGEX = const PATH_TRAVERSAL_REGEX =
/\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![0-9])/; /\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![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 * Validates a plugin name for security issues
@@ -31,10 +55,37 @@ function validatePluginName(pluginName: string): void {
} }
/** /**
* Parse a comma-separated list of plugin names and return an array of trimmed, non-empty plugin names * 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 * Validates plugin names to prevent command injection and path traversal attacks
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters) * Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
* Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots * 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[] { function parsePlugins(plugins?: string): string[] {
const trimmedPlugins = plugins?.trim(); const trimmedPlugins = plugins?.trim();
@@ -43,9 +94,9 @@ function parsePlugins(plugins?: string): string[] {
return []; return [];
} }
// Split by comma and process each plugin // Split by newline and process each plugin
return trimmedPlugins return trimmedPlugins
.split(",") .split("\n")
.map((p) => p.trim()) .map((p) => p.trim())
.filter((p) => { .filter((p) => {
if (p.length === 0) return false; if (p.length === 0) return false;
@@ -91,11 +142,17 @@ async function executeClaudeCommand(
/** /**
* Installs a single Claude Code plugin * 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( async function installPlugin(
pluginName: string, pluginName: string,
claudeExecutable: string, claudeExecutable: string,
): Promise<void> { ): Promise<void> {
console.log(`Installing plugin: ${pluginName}`);
return executeClaudeCommand( return executeClaudeCommand(
claudeExecutable, claudeExecutable,
["plugin", "install", pluginName], ["plugin", "install", pluginName],
@@ -104,52 +161,62 @@ async function installPlugin(
} }
/** /**
* Adds the Claude Code marketplace * Adds a Claude Code plugin marketplace
* @param claudeExecutable - Path to the Claude executable * @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 * @returns Promise that resolves when the marketplace add command completes
* @throws {Error} If the command fails to execute * @throws {Error} If the command fails to execute
*/ */
async function addMarketplace(claudeExecutable: string): Promise<void> { async function addMarketplace(
console.log("Adding Claude Code marketplace..."); claudeExecutable: string,
marketplaceUrl: string,
): Promise<void> {
console.log(`Adding marketplace: ${marketplaceUrl}`);
return executeClaudeCommand( return executeClaudeCommand(
claudeExecutable, claudeExecutable,
["plugin", "marketplace", "add", CLAUDE_CODE_MARKETPLACE_URL], ["plugin", "marketplace", "add", marketplaceUrl],
"Failed to add marketplace", `Failed to add marketplace '${marketplaceUrl}'`,
); );
} }
/** /**
* Installs Claude Code plugins from a comma-separated list * Installs Claude Code plugins from a newline-separated list
* @param pluginsInput - Comma-separated list of plugin names, or undefined/empty to skip installation * @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") * @param claudeExecutable - Path to the Claude executable (defaults to "claude")
* @returns Promise that resolves when all plugins are installed * @returns Promise that resolves when all plugins are installed
* @throws {Error} If any plugin fails validation or installation (stops on first error) * @throws {Error} If any plugin fails validation or installation (stops on first error)
*/ */
export async function installPlugins( export async function installPlugins(
pluginsInput: string | undefined, marketplacesInput?: string,
pluginsInput?: string,
claudeExecutable?: string, claudeExecutable?: string,
): Promise<void> { ): Promise<void> {
const plugins = parsePlugins(pluginsInput);
if (plugins.length === 0) {
console.log("No plugins to install");
return;
}
// Resolve executable path with explicit fallback // Resolve executable path with explicit fallback
const resolvedExecutable = claudeExecutable || "claude"; const resolvedExecutable = claudeExecutable || "claude";
// Add marketplace before installing plugins // Parse and add all marketplaces before installing plugins
await addMarketplace(resolvedExecutable); 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)...`); console.log(`Installing ${plugins.length} plugin(s)...`);
for (const plugin of plugins) { for (const plugin of plugins) {
console.log(`Installing plugin: ${plugin}`);
await installPlugin(plugin, resolvedExecutable); await installPlugin(plugin, resolvedExecutable);
console.log(`✓ Successfully installed: ${plugin}`); console.log(`✓ Successfully installed: ${plugin}`);
} }
} else {
console.log("All plugins installed successfully"); console.log("No plugins specified, skipping plugins installation");
}
} }

View File

@@ -1,7 +1,7 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { exec } from "child_process"; import { exec } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { unlink, writeFile, stat } from "fs/promises"; import { unlink, writeFile, stat, readFile } from "fs/promises";
import { createWriteStream } from "fs"; import { createWriteStream } from "fs";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { parse as parseShellArgs } from "shell-quote"; import { parse as parseShellArgs } from "shell-quote";
@@ -12,6 +12,59 @@ const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["--verbose", "--output-format", "stream-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 = { export type ClaudeOptions = {
claudeArgs?: string; claudeArgs?: string;
model?: string; model?: string;
@@ -24,6 +77,7 @@ export type ClaudeOptions = {
appendSystemPrompt?: string; appendSystemPrompt?: string;
claudeEnv?: string; claudeEnv?: string;
fallbackModel?: string; fallbackModel?: string;
showFullOutput?: string;
}; };
type PreparedConfig = { type PreparedConfig = {
@@ -68,6 +122,48 @@ export function prepareRunConfig(
}; };
} }
/**
* Parses structured_output from execution file and sets GitHub Action outputs
* Only runs if json_schema was explicitly provided by the user
* Exported for testing
*/
export async function parseAndSetStructuredOutputs(
executionFile: string,
): Promise<void> {
try {
const content = await readFile(executionFile, "utf-8");
const messages = JSON.parse(content) as {
type: string;
structured_output?: Record<string, unknown>;
}[];
// Search backwards - result is typically last or second-to-last message
const result = messages.findLast(
(m) => m.type === "result" && m.structured_output,
);
if (!result?.structured_output) {
throw new Error(
`json_schema was provided but Claude did not return structured_output.\n` +
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
);
}
// Set the complete structured output as a single JSON string
// This works around GitHub Actions limitation that composite actions can't have dynamic outputs
const structuredOutputJson = JSON.stringify(result.structured_output);
core.setOutput("structured_output", structuredOutputJson);
core.info(
`Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
);
} catch (error) {
if (error instanceof Error) {
throw error; // Preserve original error and stack trace
}
throw new Error(`Failed to parse structured outputs: ${error}`);
}
}
export async function runClaude(promptPath: string, options: ClaudeOptions) { export async function runClaude(promptPath: string, options: ClaudeOptions) {
const config = prepareRunConfig(promptPath, options); const config = prepareRunConfig(promptPath, options);
@@ -138,12 +234,27 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
pipeStream.destroy(); 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 // Capture output for parsing execution metrics
let output = ""; let output = "";
claudeProcess.stdout.on("data", (data) => { claudeProcess.stdout.on("data", (data) => {
const text = data.toString(); const text = data.toString();
// Try to parse as JSON and pretty print if it's on a single line // Try to parse as JSON and handle based on verbose setting
const lines = text.split("\n"); const lines = text.split("\n");
lines.forEach((line: string, index: number) => { lines.forEach((line: string, index: number) => {
if (line.trim() === "") return; if (line.trim() === "") return;
@@ -151,18 +262,25 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
try { try {
// Check if this line is a JSON object // Check if this line is a JSON object
const parsed = JSON.parse(line); const parsed = JSON.parse(line);
const prettyJson = JSON.stringify(parsed, null, 2); const sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);
process.stdout.write(prettyJson);
if (sanitizedOutput) {
process.stdout.write(sanitizedOutput);
if (index < lines.length - 1 || text.endsWith("\n")) { if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n"); process.stdout.write("\n");
} }
}
} catch (e) { } catch (e) {
// Not a JSON object, print as is // Not a JSON object
if (showFullOutput) {
// In full output mode, print as is
process.stdout.write(line); process.stdout.write(line);
if (index < lines.length - 1 || text.endsWith("\n")) { if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n"); process.stdout.write("\n");
} }
} }
// In non-full-output mode, suppress non-JSON output
}
}); });
output += text; output += text;
@@ -232,8 +350,23 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
core.warning(`Failed to process output for execution metrics: ${e}`); core.warning(`Failed to process output for execution metrics: ${e}`);
} }
core.setOutput("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE); core.setOutput("execution_file", EXECUTION_FILE);
// Parse and set structured outputs only if user provided json_schema
if (process.env.JSON_SCHEMA) {
try {
await parseAndSetStructuredOutputs(EXECUTION_FILE);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
core.setFailed(errorMessage);
core.setOutput("conclusion", "failure");
process.exit(1);
}
}
// Set conclusion to success if we reached here
core.setOutput("conclusion", "success");
} else { } else {
core.setOutput("conclusion", "failure"); core.setOutput("conclusion", "failure");

View File

@@ -39,43 +39,31 @@ describe("installPlugins", () => {
test("should not call spawn when no plugins are specified", async () => { test("should not call spawn when no plugins are specified", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins(""); await installPlugins(undefined, "");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
test("should not call spawn when plugins is undefined", async () => { test("should not call spawn when plugins is undefined", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins(undefined); await installPlugins(undefined, undefined);
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
test("should not call spawn when plugins is only whitespace", async () => { test("should not call spawn when plugins is only whitespace", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins(" "); await installPlugins(undefined, " ");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
test("should install a single plugin with default executable", async () => { test("should install a single plugin with default executable", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("test-plugin"); await installPlugins(undefined, "test-plugin");
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(1);
// First call: add marketplace // Only call: install plugin (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"claude", "claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
// Second call: install plugin
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "test-plugin"], ["plugin", "install", "test-plugin"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
@@ -83,36 +71,24 @@ describe("installPlugins", () => {
test("should install multiple plugins sequentially", async () => { test("should install multiple plugins sequentially", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("plugin1,plugin2,plugin3"); await installPlugins(undefined, "plugin1\nplugin2\nplugin3");
expect(spy).toHaveBeenCalledTimes(4); expect(spy).toHaveBeenCalledTimes(3);
// First call: add marketplace // Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"claude", "claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
// Subsequent calls: install plugins
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin1"], ["plugin", "install", "plugin1"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
3, 2,
"claude", "claude",
["plugin", "install", "plugin2"], ["plugin", "install", "plugin2"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
4, 3,
"claude", "claude",
["plugin", "install", "plugin3"], ["plugin", "install", "plugin3"],
{ stdio: "inherit" }, { stdio: "inherit" },
@@ -121,25 +97,13 @@ describe("installPlugins", () => {
test("should use custom claude executable path when provided", async () => { test("should use custom claude executable path when provided", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("test-plugin", "/custom/path/to/claude"); await installPlugins(undefined, "test-plugin", "/custom/path/to/claude");
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(1);
// First call: add marketplace // Only call: install plugin (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"/custom/path/to/claude", "/custom/path/to/claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
// Second call: install plugin
expect(spy).toHaveBeenNthCalledWith(
2,
"/custom/path/to/claude",
["plugin", "install", "test-plugin"], ["plugin", "install", "test-plugin"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
@@ -147,29 +111,18 @@ describe("installPlugins", () => {
test("should trim whitespace from plugin names before installation", async () => { test("should trim whitespace from plugin names before installation", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins(" plugin1 , plugin2 "); await installPlugins(undefined, " plugin1 \n plugin2 ");
expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenCalledTimes(2);
// First call: add marketplace // Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"claude", "claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin1"], ["plugin", "install", "plugin1"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
3, 2,
"claude", "claude",
["plugin", "install", "plugin2"], ["plugin", "install", "plugin2"],
{ stdio: "inherit" }, { stdio: "inherit" },
@@ -178,29 +131,18 @@ describe("installPlugins", () => {
test("should skip empty entries in plugin list", async () => { test("should skip empty entries in plugin list", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("plugin1,,plugin2"); await installPlugins(undefined, "plugin1\n\nplugin2");
expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenCalledTimes(2);
// First call: add marketplace // Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"claude", "claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin1"], ["plugin", "install", "plugin1"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
3, 2,
"claude", "claude",
["plugin", "install", "plugin2"], ["plugin", "install", "plugin2"],
{ stdio: "inherit" }, { stdio: "inherit" },
@@ -210,55 +152,46 @@ describe("installPlugins", () => {
test("should handle plugin installation error and throw", async () => { test("should handle plugin installation error and throw", async () => {
createMockSpawn(1, false); // Exit code 1 createMockSpawn(1, false); // Exit code 1
await expect(installPlugins("failing-plugin")).rejects.toThrow( await expect(installPlugins(undefined, "failing-plugin")).rejects.toThrow(
"Failed to add marketplace (exit code: 1)", "Failed to install plugin 'failing-plugin' (exit code: 1)",
); );
}); });
test("should handle null exit code (process terminated by signal)", async () => { test("should handle null exit code (process terminated by signal)", async () => {
createMockSpawn(null, false); // Exit code null (terminated by signal) createMockSpawn(null, false); // Exit code null (terminated by signal)
await expect(installPlugins("terminated-plugin")).rejects.toThrow( await expect(
"Failed to add marketplace: process terminated by signal", installPlugins(undefined, "terminated-plugin"),
).rejects.toThrow(
"Failed to install plugin 'terminated-plugin': process terminated by signal",
); );
}); });
test("should stop installation on first error", async () => { test("should stop installation on first error", async () => {
const spy = createMockSpawn(1, false); // Exit code 1 const spy = createMockSpawn(1, false); // Exit code 1
await expect(installPlugins("plugin1,plugin2,plugin3")).rejects.toThrow( await expect(
"Failed to add marketplace (exit code: 1)", installPlugins(undefined, "plugin1\nplugin2\nplugin3"),
); ).rejects.toThrow("Failed to install plugin 'plugin1' (exit code: 1)");
// Should only try to add marketplace before failing // Should only try to install first plugin before failing
expect(spy).toHaveBeenCalledTimes(1); expect(spy).toHaveBeenCalledTimes(1);
}); });
test("should handle plugins with special characters in names", async () => { test("should handle plugins with special characters in names", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("org/plugin-name,@scope/plugin"); await installPlugins(undefined, "org/plugin-name\n@scope/plugin");
expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenCalledTimes(2);
// First call: add marketplace // Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"claude", "claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "org/plugin-name"], ["plugin", "install", "org/plugin-name"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
3, 2,
"claude", "claude",
["plugin", "install", "@scope/plugin"], ["plugin", "install", "@scope/plugin"],
{ stdio: "inherit" }, { stdio: "inherit" },
@@ -268,36 +201,29 @@ describe("installPlugins", () => {
test("should handle spawn errors", async () => { test("should handle spawn errors", async () => {
createMockSpawn(0, true); // Trigger error event createMockSpawn(0, true); // Trigger error event
await expect(installPlugins("test-plugin")).rejects.toThrow( await expect(installPlugins(undefined, "test-plugin")).rejects.toThrow(
"Failed to add marketplace: spawn error", "Failed to install plugin 'test-plugin': spawn error",
); );
}); });
test("should install plugins with custom executable and multiple plugins", async () => { test("should install plugins with custom executable and multiple plugins", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("plugin-a,plugin-b", "/usr/local/bin/claude-custom"); await installPlugins(
undefined,
"plugin-a\nplugin-b",
"/usr/local/bin/claude-custom",
);
expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenCalledTimes(2);
// First call: add marketplace // Install plugins (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"/usr/local/bin/claude-custom", "/usr/local/bin/claude-custom",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"/usr/local/bin/claude-custom",
["plugin", "install", "plugin-a"], ["plugin", "install", "plugin-a"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
3, 2,
"/usr/local/bin/claude-custom", "/usr/local/bin/claude-custom",
["plugin", "install", "plugin-b"], ["plugin", "install", "plugin-b"],
{ stdio: "inherit" }, { stdio: "inherit" },
@@ -308,9 +234,9 @@ describe("installPlugins", () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
// Should throw due to invalid characters (semicolon and spaces) // Should throw due to invalid characters (semicolon and spaces)
await expect(installPlugins("plugin-name; rm -rf /")).rejects.toThrow( await expect(
"Invalid plugin name format", installPlugins(undefined, "plugin-name; rm -rf /"),
); ).rejects.toThrow("Invalid plugin name format");
// Mock should never be called because validation fails first // Mock should never be called because validation fails first
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
@@ -319,9 +245,9 @@ describe("installPlugins", () => {
test("should reject plugin names with path traversal using ../", async () => { test("should reject plugin names with path traversal using ../", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("../../../malicious-plugin")).rejects.toThrow( await expect(
"Invalid plugin name format", installPlugins(undefined, "../../../malicious-plugin"),
); ).rejects.toThrow("Invalid plugin name format");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
@@ -329,9 +255,9 @@ describe("installPlugins", () => {
test("should reject plugin names with path traversal using ./", async () => { test("should reject plugin names with path traversal using ./", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("./../../@scope/package")).rejects.toThrow( await expect(
"Invalid plugin name format", installPlugins(undefined, "./../../@scope/package"),
); ).rejects.toThrow("Invalid plugin name format");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
@@ -339,7 +265,7 @@ describe("installPlugins", () => {
test("should reject plugin names with consecutive dots", async () => { test("should reject plugin names with consecutive dots", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins(".../.../package")).rejects.toThrow( await expect(installPlugins(undefined, ".../.../package")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -349,7 +275,7 @@ describe("installPlugins", () => {
test("should reject plugin names with hidden path traversal", async () => { test("should reject plugin names with hidden path traversal", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("package/../other")).rejects.toThrow( await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -358,24 +284,13 @@ describe("installPlugins", () => {
test("should accept plugin names with single dots in version numbers", async () => { test("should accept plugin names with single dots in version numbers", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("plugin-v1.0.2"); await installPlugins(undefined, "plugin-v1.0.2");
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(1);
// First call: add marketplace // Only call: install plugin (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"claude", "claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "plugin-v1.0.2"], ["plugin", "install", "plugin-v1.0.2"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
@@ -383,24 +298,13 @@ describe("installPlugins", () => {
test("should accept plugin names with multiple dots in semantic versions", async () => { test("should accept plugin names with multiple dots in semantic versions", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await installPlugins("@scope/plugin-v1.0.0-beta.1"); await installPlugins(undefined, "@scope/plugin-v1.0.0-beta.1");
expect(spy).toHaveBeenCalledTimes(2); expect(spy).toHaveBeenCalledTimes(1);
// First call: add marketplace // Only call: install plugin (no marketplace without explicit marketplace input)
expect(spy).toHaveBeenNthCalledWith( expect(spy).toHaveBeenNthCalledWith(
1, 1,
"claude", "claude",
[
"plugin",
"marketplace",
"add",
"https://github.com/anthropics/claude-code.git",
],
{ stdio: "inherit" },
);
expect(spy).toHaveBeenNthCalledWith(
2,
"claude",
["plugin", "install", "@scope/plugin-v1.0.0-beta.1"], ["plugin", "install", "@scope/plugin-v1.0.0-beta.1"],
{ stdio: "inherit" }, { stdio: "inherit" },
); );
@@ -410,7 +314,7 @@ describe("installPlugins", () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F) // Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
await expect(installPlugins("malicious")).rejects.toThrow( await expect(installPlugins(undefined, "malicious")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -420,7 +324,7 @@ describe("installPlugins", () => {
test("should reject path traversal at end of path", async () => { test("should reject path traversal at end of path", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("package/..")).rejects.toThrow( await expect(installPlugins(undefined, "package/..")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -430,7 +334,7 @@ describe("installPlugins", () => {
test("should reject single dot directory reference", async () => { test("should reject single dot directory reference", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("package/.")).rejects.toThrow( await expect(installPlugins(undefined, "package/.")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
@@ -440,10 +344,256 @@ describe("installPlugins", () => {
test("should reject path traversal in middle of path", async () => { test("should reject path traversal in middle of path", async () => {
const spy = createMockSpawn(); const spy = createMockSpawn();
await expect(installPlugins("package/../other")).rejects.toThrow( await expect(installPlugins(undefined, "package/../other")).rejects.toThrow(
"Invalid plugin name format", "Invalid plugin name format",
); );
expect(spy).not.toHaveBeenCalled(); 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

@@ -78,5 +78,19 @@ describe("prepareRunConfig", () => {
"stream-json", "stream-json",
]); ]);
}); });
test("should include json-schema flag when provided", () => {
const options: ClaudeOptions = {
claudeArgs:
'--json-schema \'{"type":"object","properties":{"result":{"type":"boolean"}}}\'',
};
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
expect(prepared.claudeArgs).toContain("--json-schema");
expect(prepared.claudeArgs).toContain(
'{"type":"object","properties":{"result":{"type":"boolean"}}}',
);
});
}); });
}); });

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env bun
import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
import { writeFile, unlink } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { parseAndSetStructuredOutputs } from "../src/run-claude";
import * as core from "@actions/core";
// Mock execution file path
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
// Helper to create mock execution file with structured output
async function createMockExecutionFile(
structuredOutput?: Record<string, unknown>,
includeResult: boolean = true,
): Promise<void> {
const messages: any[] = [
{ type: "system", subtype: "init" },
{ type: "turn", content: "test" },
];
if (includeResult) {
messages.push({
type: "result",
cost_usd: 0.01,
duration_ms: 1000,
structured_output: structuredOutput,
});
}
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
}
// Spy on core functions
let setOutputSpy: any;
let infoSpy: any;
beforeEach(() => {
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
infoSpy = spyOn(core, "info").mockImplementation(() => {});
});
describe("parseAndSetStructuredOutputs", () => {
afterEach(async () => {
setOutputSpy?.mockRestore();
infoSpy?.mockRestore();
try {
await unlink(TEST_EXECUTION_FILE);
} catch {
// Ignore if file doesn't exist
}
});
test("should set structured_output with valid data", async () => {
await createMockExecutionFile({
is_flaky: true,
confidence: 0.85,
summary: "Test looks flaky",
});
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
expect(setOutputSpy).toHaveBeenCalledWith(
"structured_output",
'{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}',
);
expect(infoSpy).toHaveBeenCalledWith(
"Set structured_output with 3 field(s)",
);
});
test("should handle arrays and nested objects", async () => {
await createMockExecutionFile({
items: ["a", "b", "c"],
config: { key: "value", nested: { deep: true } },
});
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
const callArgs = setOutputSpy.mock.calls[0];
expect(callArgs[0]).toBe("structured_output");
const parsed = JSON.parse(callArgs[1]);
expect(parsed).toEqual({
items: ["a", "b", "c"],
config: { key: "value", nested: { deep: true } },
});
});
test("should handle special characters in field names", async () => {
await createMockExecutionFile({
"test-result": "passed",
"item.count": 10,
"user@email": "test",
});
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
const callArgs = setOutputSpy.mock.calls[0];
const parsed = JSON.parse(callArgs[1]);
expect(parsed["test-result"]).toBe("passed");
expect(parsed["item.count"]).toBe(10);
expect(parsed["user@email"]).toBe("test");
});
test("should throw error when result exists but structured_output is undefined", async () => {
const messages = [
{ type: "system", subtype: "init" },
{ type: "result", cost_usd: 0.01, duration_ms: 1000 },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await expect(
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
).rejects.toThrow(
"json_schema was provided but Claude did not return structured_output",
);
});
test("should throw error when no result message exists", async () => {
const messages = [
{ type: "system", subtype: "init" },
{ type: "turn", content: "test" },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
await expect(
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
).rejects.toThrow(
"json_schema was provided but Claude did not return structured_output",
);
});
test("should throw error with malformed JSON", async () => {
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
await expect(
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
).rejects.toThrow();
});
test("should throw error when file does not exist", async () => {
await expect(
parseAndSetStructuredOutputs("/nonexistent/file.json"),
).rejects.toThrow();
});
test("should handle empty structured_output object", async () => {
await createMockExecutionFile({});
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
expect(infoSpy).toHaveBeenCalledWith(
"Set structured_output with 0 field(s)",
);
});
});

View File

@@ -130,7 +130,7 @@ To allow Claude to view workflow run results, job logs, and CI status:
2. **Configure the action with additional permissions**: 2. **Configure the action with additional permissions**:
```yaml ```yaml
- uses: anthropics/claude-code-action@beta - uses: anthropics/claude-code-action@v1
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
additional_permissions: | additional_permissions: |
@@ -162,7 +162,7 @@ jobs:
claude-ci-helper: claude-ci-helper:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: anthropics/claude-code-action@beta - uses: anthropics/claude-code-action@v1
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
additional_permissions: | additional_permissions: |

744
docs/create-app.html Normal file
View File

@@ -0,0 +1,744 @@
<!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

@@ -56,3 +56,31 @@ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable! anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
claude_code_oauth_token: "oauth_token_..." # 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,7 +20,48 @@ If you prefer not to install the official Claude app, you can create your own Gi
- Organization policies prevent installing third-party apps - Organization policies prevent installing third-party apps
- You're using AWS Bedrock or Google Vertex AI - You're using AWS Bedrock or Google Vertex AI
**Steps to create and use a custom GitHub App:** ### 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:
1. **Create a new GitHub App:** 1. **Create a new GitHub App:**
@@ -76,7 +117,7 @@ If you prefer not to install the official Claude app, you can create your own Gi
private-key: ${{ secrets.APP_PRIVATE_KEY }} private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Use Claude with your custom app's token # Use Claude with your custom app's token
- uses: anthropics/claude-code-action@beta - uses: anthropics/claude-code-action@v1
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.app-token.outputs.token }} github_token: ${{ steps.app-token.outputs.token }}

View File

@@ -32,8 +32,10 @@ jobs:
# --max-turns 10 # --max-turns 10
# --model claude-4-0-sonnet-20250805 # --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 # Optional: install Claude Code plugins
# plugins: "plugin1,plugin2,plugin3" # plugins: "code-review@claude-code-plugins\nfeature-dev@claude-code-plugins"
# Optional: add custom trigger phrase (default: @claude) # Optional: add custom trigger phrase (default: @claude)
# trigger_phrase: "/claude" # trigger_phrase: "/claude"
@@ -76,7 +78,9 @@ jobs:
| `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 | "" | | `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_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 | "" | | `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
| `plugins` | Comma-separated list of Claude Code plugin names to install (e.g., `plugin1,plugin2,plugin3`). Plugins are installed before Claude Code execution | 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 | "" |
| `json_schema` | JSON schema for structured output validation. Automatically sets GitHub Action outputs for each field. See [Structured Outputs](#structured-outputs) section below | No | "" |
### Deprecated Inputs ### Deprecated Inputs
@@ -182,6 +186,82 @@ For a comprehensive guide on migrating from v0.x to v1.0, including step-by-step
Focus on the changed files in this PR. Focus on the changed files in this PR.
``` ```
## Structured Outputs
Get validated JSON results from Claude that automatically become GitHub Action outputs. This enables building complex automation workflows where Claude analyzes data and subsequent steps use the results.
### Basic Example
```yaml
- name: Detect flaky tests
id: analyze
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
Check the CI logs and determine if this is a flaky test.
Return: is_flaky (boolean), confidence (0-1), summary (string)
json_schema: |
{
"type": "object",
"properties": {
"is_flaky": {"type": "boolean"},
"confidence": {"type": "number"},
"summary": {"type": "string"}
},
"required": ["is_flaky"]
}
- name: Retry if flaky
if: fromJSON(steps.analyze.outputs.structured_output).is_flaky == true
run: gh workflow run CI
```
### How It Works
1. **Define Schema**: Provide a JSON schema in the `json_schema` input
2. **Claude Executes**: Claude uses tools to complete your task
3. **Validated Output**: Result is validated against your schema
4. **JSON Output**: All fields are returned in a single `structured_output` JSON string
### Accessing Structured Outputs
All structured output fields are available in the `structured_output` output as a JSON string:
**In GitHub Actions expressions:**
```yaml
if: fromJSON(steps.analyze.outputs.structured_output).is_flaky == true
run: |
CONFIDENCE=${{ fromJSON(steps.analyze.outputs.structured_output).confidence }}
```
**In bash with jq:**
```yaml
- name: Process results
run: |
OUTPUT='${{ steps.analyze.outputs.structured_output }}'
IS_FLAKY=$(echo "$OUTPUT" | jq -r '.is_flaky')
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
```
**Note**: Due to GitHub Actions limitations, composite actions cannot expose dynamic outputs. All fields are bundled in the single `structured_output` JSON string.
### Complete Example
See `examples/test-failure-analysis.yml` for a working example that:
- Detects flaky test failures
- Uses confidence thresholds in conditionals
- Auto-retries workflows
- Comments on PRs
### Documentation
For complete details on JSON Schema syntax and Agent SDK structured outputs:
https://docs.claude.com/en/docs/agent-sdk/structured-outputs
## Ways to Tag @claude ## Ways to Tag @claude
These examples show how to interact with Claude using comments in PRs and issues. By default, Claude will be triggered anytime you mention `@claude`, but you can customize the exact trigger phrase using the `trigger_phrase` input in the workflow. These examples show how to interact with Claude using comments in PRs and issues. By default, Claude will be triggered anytime you mention `@claude`, but you can customize the exact trigger phrase using the `trigger_phrase` input in the workflow.

View File

@@ -0,0 +1,133 @@
name: Auto-Retry Flaky Tests
# This example demonstrates using structured outputs to detect flaky test failures
# and automatically retry them, reducing noise from intermittent failures.
#
# Use case: When CI fails, automatically determine if it's likely flaky and retry if so.
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
contents: read
actions: write
jobs:
detect-flaky:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Detect flaky test failures
id: detect
uses: anthropics/claude-code-action@main
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt: |
The CI workflow failed: ${{ github.event.workflow_run.html_url }}
Check the logs: gh run view ${{ github.event.workflow_run.id }} --log-failed
Determine if this looks like a flaky test failure by checking for:
- Timeout errors
- Race conditions
- Network errors
- "Expected X but got Y" intermittent failures
- Tests that passed in previous commits
Return:
- is_flaky: true if likely flaky, false if real bug
- confidence: number 0-1 indicating confidence level
- summary: brief one-sentence explanation
json_schema: |
{
"type": "object",
"properties": {
"is_flaky": {
"type": "boolean",
"description": "Whether this appears to be a flaky test failure"
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence level in the determination"
},
"summary": {
"type": "string",
"description": "One-sentence explanation of the failure"
}
},
"required": ["is_flaky", "confidence", "summary"]
}
# Auto-retry only if flaky AND high confidence (>= 0.7)
- name: Retry flaky tests
if: |
fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
fromJSON(steps.detect.outputs.structured_output).confidence >= 0.7
env:
GH_TOKEN: ${{ github.token }}
run: |
OUTPUT='${{ steps.detect.outputs.structured_output }}'
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
echo "🔄 Flaky test detected (confidence: $CONFIDENCE)"
echo "Summary: $SUMMARY"
echo ""
echo "Triggering automatic retry..."
gh workflow run "${{ github.event.workflow_run.name }}" \
--ref "${{ github.event.workflow_run.head_branch }}"
# Low confidence flaky detection - skip retry
- name: Low confidence detection
if: |
fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
fromJSON(steps.detect.outputs.structured_output).confidence < 0.7
run: |
OUTPUT='${{ steps.detect.outputs.structured_output }}'
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
echo "⚠️ Possible flaky test but confidence too low ($CONFIDENCE)"
echo "Not retrying automatically - manual review recommended"
# Comment on PR if this was a PR build
- name: Comment on PR
if: github.event.workflow_run.event == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
OUTPUT='${{ steps.detect.outputs.structured_output }}'
IS_FLAKY=$(echo "$OUTPUT" | jq -r '.is_flaky')
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
pr_number=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" --json number --jq '.[0].number')
if [ -n "$pr_number" ]; then
if [ "$IS_FLAKY" = "true" ]; then
TITLE="🔄 Flaky Test Detected"
ACTION="✅ Automatically retrying the workflow"
else
TITLE="❌ Test Failure"
ACTION="⚠️ This appears to be a real bug - manual intervention needed"
fi
gh pr comment "$pr_number" --body "$(cat <<EOF
## $TITLE
**Analysis**: $SUMMARY
**Confidence**: $CONFIDENCE
$ACTION
[View workflow run](${{ github.event.workflow_run.html_url }})
EOF
)"
fi

27
github-app-manifest.json Normal file
View File

@@ -0,0 +1,27 @@
{
"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

@@ -95,7 +95,6 @@ type BaseContext = {
allowedBots: string; allowedBots: string;
allowedNonWriteUsers: string; allowedNonWriteUsers: string;
trackProgress: boolean; trackProgress: boolean;
plugins: string[];
}; };
}; };
@@ -151,10 +150,6 @@ export function parseGitHubContext(): GitHubContext {
allowedBots: process.env.ALLOWED_BOTS ?? "", allowedBots: process.env.ALLOWED_BOTS ?? "",
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "", allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
trackProgress: process.env.TRACK_PROGRESS === "true", trackProgress: process.env.TRACK_PROGRESS === "true",
plugins: (process.env.PLUGINS || "")
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0),
}, },
}; };

View File

@@ -134,17 +134,11 @@ export async function prepareMcpConfig(
}; };
} }
// Check if code-review plugin is in the plugins list
const hasCodeReviewPlugin = context.inputs.plugins.includes(
"code-review@claude-code-plugins",
);
// Include inline comment server for PRs when requested via allowed tools // Include inline comment server for PRs when requested via allowed tools
// or when code-review plugin is specified (needs inline comment access for reviews)
if ( if (
isEntityContext(context) && isEntityContext(context) &&
context.isPR && context.isPR &&
(hasGitHubMcpTools || hasInlineCommentTools || hasCodeReviewPlugin) (hasGitHubMcpTools || hasInlineCommentTools)
) { ) {
baseMcpConfig.mcpServers.github_inline_comment = { baseMcpConfig.mcpServers.github_inline_comment = {
command: "bun", command: "bun",

View File

@@ -7,6 +7,7 @@ import { parseAllowedTools } from "./parse-tools";
import { configureGitAuth } from "../../github/operations/git-config"; import { configureGitAuth } from "../../github/operations/git-config";
import type { GitHubContext } from "../../github/context"; import type { GitHubContext } from "../../github/context";
import { isEntityContext } from "../../github/context"; import { isEntityContext } from "../../github/context";
import { appendJsonSchemaArg } from "../../utils/json-schema";
/** /**
* Extract GitHub context as environment variables for agent mode * Extract GitHub context as environment variables for agent mode
@@ -114,14 +115,6 @@ export const agentMode: Mode = {
const userClaudeArgs = process.env.CLAUDE_ARGS || ""; const userClaudeArgs = process.env.CLAUDE_ARGS || "";
const allowedTools = parseAllowedTools(userClaudeArgs); const allowedTools = parseAllowedTools(userClaudeArgs);
// Add inline comment tool if code-review plugin is present
const hasCodeReviewPlugin = context.inputs.plugins.includes(
"code-review@claude-code-plugins",
);
if (hasCodeReviewPlugin && isEntityContext(context) && context.isPR) {
allowedTools.push("mcp__github_inline_comment__create_inline_comment");
}
// Check for branch info from environment variables (useful for auto-fix workflows) // Check for branch info from environment variables (useful for auto-fix workflows)
const claudeBranch = process.env.CLAUDE_BRANCH || undefined; const claudeBranch = process.env.CLAUDE_BRANCH || undefined;
const baseBranch = const baseBranch =
@@ -157,6 +150,9 @@ export const agentMode: Mode = {
claudeArgs = `--mcp-config '${escapedOurConfig}'`; claudeArgs = `--mcp-config '${escapedOurConfig}'`;
} }
// Add JSON schema if provided
claudeArgs = appendJsonSchemaArg(claudeArgs);
// Append user's claude_args (which may have more --mcp-config flags) // Append user's claude_args (which may have more --mcp-config flags)
claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim(); claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim();

View File

@@ -15,6 +15,7 @@ import { isEntityContext } from "../../github/context";
import type { PreparedContext } from "../../create-prompt/types"; import type { PreparedContext } from "../../create-prompt/types";
import type { FetchDataResult } from "../../github/data/fetcher"; import type { FetchDataResult } from "../../github/data/fetcher";
import { parseAllowedTools } from "../agent/parse-tools"; import { parseAllowedTools } from "../agent/parse-tools";
import { appendJsonSchemaArg } from "../../utils/json-schema";
/** /**
* Tag mode implementation. * Tag mode implementation.
@@ -177,6 +178,9 @@ export const tagMode: Mode = {
// Add required tools for tag mode // Add required tools for tag mode
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`; claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
// Add JSON schema if provided
claudeArgs = appendJsonSchemaArg(claudeArgs);
// Append user's claude_args (which may have more --mcp-config flags) // Append user's claude_args (which may have more --mcp-config flags)
if (userClaudeArgs) { if (userClaudeArgs) {
claudeArgs += ` ${userClaudeArgs}`; claudeArgs += ` ${userClaudeArgs}`;

17
src/utils/json-schema.ts Normal file
View File

@@ -0,0 +1,17 @@
/**
* Appends JSON schema CLI argument if json_schema is provided
* Escapes schema for safe shell passing
*/
export function appendJsonSchemaArg(
claudeArgs: string,
jsonSchemaStr?: string,
): string {
const schema = jsonSchemaStr || process.env.JSON_SCHEMA || "";
if (!schema) {
return claudeArgs;
}
// CLI validates schema - just escape for safe shell passing
const escapedSchema = schema.replace(/'/g, "'\\''");
return `${claudeArgs} --json-schema '${escapedSchema}'`;
}

View File

@@ -37,7 +37,6 @@ describe("prepareMcpConfig", () => {
allowedBots: "", allowedBots: "",
allowedNonWriteUsers: "", allowedNonWriteUsers: "",
trackProgress: false, trackProgress: false,
plugins: [],
}, },
}; };
@@ -277,111 +276,4 @@ describe("prepareMcpConfig", () => {
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined(); expect(parsed.mcpServers.github_ci).not.toBeDefined();
}); });
test("should include inline comment server in agent mode when code-review plugin is specified", async () => {
const contextWithCodeReviewPlugin: ParsedGitHubContext = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
plugins: ["code-review@claude-code-plugins"],
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
mode: "agent",
context: contextWithCodeReviewPlugin,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
expect(parsed.mcpServers.github_inline_comment.env.GITHUB_TOKEN).toBe(
"test-token",
);
});
test("should not include inline comment server in agent mode when code-review plugin is not specified", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
mode: "agent",
context: mockPRContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_inline_comment).not.toBeDefined();
});
test("should include inline comment server in agent mode when code-review plugin is in a list of plugins", async () => {
const contextWithMultiplePlugins: ParsedGitHubContext = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
plugins: ["plugin1", "code-review@claude-code-plugins", "plugin2"],
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
mode: "agent",
context: contextWithMultiplePlugins,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
});
test("should not include inline comment server in agent mode when plugins contain similar but not exact match", async () => {
const contextWithSimilarPlugin: ParsedGitHubContext = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
plugins: ["code-review-other", "review@claude-code-plugins"],
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
mode: "agent",
context: contextWithSimilarPlugin,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_inline_comment).not.toBeDefined();
});
test("should include inline comment server in agent mode when explicit inline comment tools are provided (backward compatibility)", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: ["mcp__github_inline_comment__create_inline_comment"],
mode: "agent",
context: mockPRContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
});
}); });

View File

@@ -25,7 +25,6 @@ const defaultInputs = {
allowedBots: "", allowedBots: "",
allowedNonWriteUsers: "", allowedNonWriteUsers: "",
trackProgress: false, trackProgress: false,
plugins: [],
}; };
const defaultRepository = { const defaultRepository = {

View File

@@ -25,7 +25,6 @@ describe("detectMode with enhanced routing", () => {
allowedBots: "", allowedBots: "",
allowedNonWriteUsers: "", allowedNonWriteUsers: "",
trackProgress: false, trackProgress: false,
plugins: [],
}, },
}; };

View File

@@ -73,7 +73,6 @@ describe("checkWritePermissions", () => {
allowedBots: "", allowedBots: "",
allowedNonWriteUsers: "", allowedNonWriteUsers: "",
trackProgress: false, trackProgress: false,
plugins: [],
}, },
}); });