mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
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>
This commit is contained in:
327
.github/workflows/test-structured-output.yml
vendored
Normal file
327
.github/workflows/test-structured-output.yml
vendored
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
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: |
|
||||||
|
# Test string pass-through
|
||||||
|
if [ "${{ steps.test.outputs.text_field }}" != "hello" ]; then
|
||||||
|
echo "❌ String: expected 'hello', got '${{ steps.test.outputs.text_field }}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test number → string conversion
|
||||||
|
if [ "${{ steps.test.outputs.number_field }}" != "42" ]; then
|
||||||
|
echo "❌ Number: expected '42', got '${{ steps.test.outputs.number_field }}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test boolean → "true" conversion
|
||||||
|
if [ "${{ steps.test.outputs.boolean_true }}" != "true" ]; then
|
||||||
|
echo "❌ Boolean true: expected 'true', got '${{ steps.test.outputs.boolean_true }}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test boolean → "false" conversion
|
||||||
|
if [ "${{ steps.test.outputs.boolean_false }}" != "false" ]; then
|
||||||
|
echo "❌ Boolean false: expected 'false', got '${{ steps.test.outputs.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: |
|
||||||
|
# Arrays should be JSON stringified
|
||||||
|
ITEMS='${{ steps.test.outputs.items }}'
|
||||||
|
if ! echo "$ITEMS" | jq -e '. | length == 3' > /dev/null; then
|
||||||
|
echo "❌ Array not properly stringified: $ITEMS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Objects should be JSON stringified
|
||||||
|
CONFIG='${{ steps.test.outputs.config }}'
|
||||||
|
if ! echo "$CONFIG" | jq -e '.key == "value"' > /dev/null; then
|
||||||
|
echo "❌ Object not properly stringified: $CONFIG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Empty arrays should work
|
||||||
|
EMPTY='${{ steps.test.outputs.empty_array }}'
|
||||||
|
if ! echo "$EMPTY" | jq -e '. | length == 0' > /dev/null; then
|
||||||
|
echo "❌ Empty array not properly stringified: $EMPTY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ All complex types JSON stringified 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: |
|
||||||
|
# Zero should be "0", not empty or falsy
|
||||||
|
if [ "${{ steps.test.outputs.zero }}" != "0" ]; then
|
||||||
|
echo "❌ Zero: expected '0', got '${{ steps.test.outputs.zero }}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Empty string should be empty (not "null" or missing)
|
||||||
|
if [ "${{ steps.test.outputs.empty_string }}" != "" ]; then
|
||||||
|
echo "❌ Empty string: expected '', got '${{ steps.test.outputs.empty_string }}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Negative numbers should work
|
||||||
|
if [ "${{ steps.test.outputs.negative }}" != "-5" ]; then
|
||||||
|
echo "❌ Negative: expected '-5', got '${{ steps.test.outputs.negative }}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Decimals should preserve precision
|
||||||
|
if [ "${{ steps.test.outputs.decimal }}" != "3.14" ]; then
|
||||||
|
echo "❌ Decimal: expected '3.14', got '${{ steps.test.outputs.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: |
|
||||||
|
# Hyphens should be preserved (GitHub Actions allows them)
|
||||||
|
if [ "${{ steps.test.outputs.test-result }}" != "passed" ]; then
|
||||||
|
echo "❌ Hyphenated name failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Underscores should work
|
||||||
|
if [ "${{ steps.test.outputs.item_count }}" != "10" ]; then
|
||||||
|
echo "❌ Underscore name failed"
|
||||||
|
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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ inputs:
|
|||||||
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')"
|
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
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
json_schema:
|
||||||
|
description: "JSON schema for structured output validation. When provided, Claude will return validated JSON matching this schema, and the action will automatically set GitHub Action outputs for each field."
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -174,6 +178,7 @@ 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) }}
|
||||||
|
|
||||||
- name: Install Base Action Dependencies
|
- name: Install Base Action Dependencies
|
||||||
@@ -228,6 +233,7 @@ runs:
|
|||||||
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
||||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
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 }}
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -67,6 +71,20 @@ inputs:
|
|||||||
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')"
|
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
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
json_schema:
|
||||||
|
description: |
|
||||||
|
JSON schema for structured output validation. Claude must return JSON matching this schema
|
||||||
|
or the action will fail. Outputs are automatically set for each field.
|
||||||
|
|
||||||
|
Access outputs via: steps.<step-id>.outputs.<field_name>
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
- Field names must start with letter or underscore (A-Z, a-z, _)
|
||||||
|
- Special characters in field names are replaced with underscores
|
||||||
|
- Each output is limited to 1MB (values will be truncated)
|
||||||
|
- Objects and arrays are JSON stringified
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
conclusion:
|
conclusion:
|
||||||
@@ -111,7 +129,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.42
|
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
|
||||||
@@ -141,6 +159,8 @@ runs:
|
|||||||
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
||||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||||
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
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 }}
|
||||||
|
|||||||
@@ -28,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
|
||||||
|
if (process.env.JSON_SCHEMA) {
|
||||||
|
const escapedSchema = process.env.JSON_SCHEMA.replace(/'/g, "'\\''");
|
||||||
|
claudeArgs += ` --json-schema '${escapedSchema}'`;
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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,14 @@ 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"];
|
||||||
|
|
||||||
|
// GitHub Actions output limits
|
||||||
|
const MAX_OUTPUT_SIZE = 1024 * 1024; // 1MB per output field
|
||||||
|
|
||||||
|
type ExecutionMessage = {
|
||||||
|
type: string;
|
||||||
|
structured_output?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitizes JSON output to remove sensitive information when full output is disabled
|
* 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
|
* Returns a safe summary message or null if the message should be completely suppressed
|
||||||
@@ -122,6 +130,140 @@ export function prepareRunConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes output field names to meet GitHub Actions output naming requirements
|
||||||
|
* GitHub outputs must be alphanumeric, hyphen, or underscore only
|
||||||
|
*/
|
||||||
|
export function sanitizeOutputName(name: string): string {
|
||||||
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved output names that cannot be used by structured outputs
|
||||||
|
const RESERVED_OUTPUTS = ["conclusion", "execution_file"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts values to string format for GitHub Actions outputs
|
||||||
|
* GitHub outputs must always be strings
|
||||||
|
*/
|
||||||
|
export function convertToString(value: unknown): string {
|
||||||
|
switch (typeof value) {
|
||||||
|
case "string":
|
||||||
|
return value;
|
||||||
|
case "boolean":
|
||||||
|
case "number":
|
||||||
|
return String(value);
|
||||||
|
case "object":
|
||||||
|
if (value === null) return "";
|
||||||
|
// Handle circular references
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch (e) {
|
||||||
|
return "[Circular or non-serializable object]";
|
||||||
|
}
|
||||||
|
case "undefined":
|
||||||
|
return "";
|
||||||
|
default:
|
||||||
|
// Handle Symbol, Function, etc.
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses structured_output from execution file and sets GitHub Action outputs
|
||||||
|
* Only runs if json_schema was explicitly provided by the user
|
||||||
|
*/
|
||||||
|
async function parseAndSetStructuredOutputs(
|
||||||
|
executionFile: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(executionFile, "utf-8");
|
||||||
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
|
||||||
|
const result = messages.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.structured_output) {
|
||||||
|
const error = new Error(
|
||||||
|
`json_schema was provided but Claude did not return structured_output.\n` +
|
||||||
|
`Found ${messages.length} messages. Result exists: ${!!result}\n` +
|
||||||
|
`The schema may be invalid or Claude failed to call the StructuredOutput tool.`,
|
||||||
|
);
|
||||||
|
core.setFailed(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set GitHub Action output for each field
|
||||||
|
const entries = Object.entries(result.structured_output);
|
||||||
|
core.info(`Setting ${entries.length} structured output(s)`);
|
||||||
|
|
||||||
|
for (const [key, value] of entries) {
|
||||||
|
// Validate key before sanitization
|
||||||
|
if (!key || key.trim() === "") {
|
||||||
|
core.warning("Skipping empty output key");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedKey = sanitizeOutputName(key);
|
||||||
|
|
||||||
|
// Ensure key starts with letter or underscore (GitHub Actions convention)
|
||||||
|
if (!/^[a-zA-Z_]/.test(sanitizedKey)) {
|
||||||
|
core.warning(
|
||||||
|
`Skipping invalid output key "${key}" (sanitized: "${sanitizedKey}")`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent shadowing reserved action outputs
|
||||||
|
if (RESERVED_OUTPUTS.includes(sanitizedKey as any)) {
|
||||||
|
core.warning(
|
||||||
|
`Skipping reserved output key "${key}" - would shadow action output "${sanitizedKey}"`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringValue = convertToString(value);
|
||||||
|
|
||||||
|
// Enforce GitHub Actions output size limit (1MB)
|
||||||
|
if (stringValue.length > MAX_OUTPUT_SIZE) {
|
||||||
|
// Don't truncate objects/arrays - would create invalid JSON
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
core.warning(
|
||||||
|
`Output "${sanitizedKey}" object/array exceeds 1MB (${stringValue.length} bytes). Skipping - reduce data size.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// For primitives, truncation is safe
|
||||||
|
core.warning(
|
||||||
|
`Output "${sanitizedKey}" exceeds 1MB (${stringValue.length} bytes), truncating`,
|
||||||
|
);
|
||||||
|
const truncated = stringValue.substring(0, MAX_OUTPUT_SIZE);
|
||||||
|
core.setOutput(sanitizedKey, truncated);
|
||||||
|
core.info(`✓ ${sanitizedKey}=[TRUNCATED ${stringValue.length} bytes]`);
|
||||||
|
} else {
|
||||||
|
// Truncate long values in logs for readability
|
||||||
|
const displayValue =
|
||||||
|
stringValue.length > 100
|
||||||
|
? `${stringValue.slice(0, 97)}...`
|
||||||
|
: stringValue;
|
||||||
|
|
||||||
|
core.setOutput(sanitizedKey, stringValue);
|
||||||
|
core.info(`✓ ${sanitizedKey}=${displayValue}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
core.setFailed(error.message);
|
||||||
|
throw error; // Preserve original error and stack trace
|
||||||
|
}
|
||||||
|
const wrappedError = new Error(
|
||||||
|
`Failed to parse structured outputs: ${error}`,
|
||||||
|
);
|
||||||
|
core.setFailed(wrappedError.message);
|
||||||
|
throw wrappedError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -308,8 +450,27 @@ 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
|
||||||
|
let structuredOutputSuccess = true;
|
||||||
|
if (process.env.JSON_SCHEMA) {
|
||||||
|
try {
|
||||||
|
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
||||||
|
} catch (error) {
|
||||||
|
structuredOutputSuccess = false;
|
||||||
|
// Error already logged by parseAndSetStructuredOutputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set conclusion after structured output parsing (which may fail)
|
||||||
|
core.setOutput(
|
||||||
|
"conclusion",
|
||||||
|
structuredOutputSuccess ? "success" : "failure",
|
||||||
|
);
|
||||||
|
if (!structuredOutputSuccess) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
|
|
||||||
|
|||||||
@@ -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"}}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
325
base-action/test/structured-output.test.ts
Normal file
325
base-action/test/structured-output.test.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { describe, test, expect, afterEach } from "bun:test";
|
||||||
|
import { writeFile, unlink } from "fs/promises";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { sanitizeOutputName, convertToString } from "../src/run-claude";
|
||||||
|
|
||||||
|
// Import the type for testing
|
||||||
|
type ExecutionMessage = {
|
||||||
|
type: string;
|
||||||
|
structured_output?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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: ExecutionMessage[] = [
|
||||||
|
{ type: "system", subtype: "init" } as any,
|
||||||
|
{ type: "turn", content: "test" } as any,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (includeResult) {
|
||||||
|
messages.push({
|
||||||
|
type: "result",
|
||||||
|
cost_usd: 0.01,
|
||||||
|
duration_ms: 1000,
|
||||||
|
structured_output: structuredOutput,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Structured Output - Pure Functions", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
try {
|
||||||
|
await unlink(TEST_EXECUTION_FILE);
|
||||||
|
} catch {
|
||||||
|
// Ignore if file doesn't exist
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sanitizeOutputName", () => {
|
||||||
|
test("should keep valid characters", () => {
|
||||||
|
expect(sanitizeOutputName("valid_name-123")).toBe("valid_name-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should replace invalid characters with underscores", () => {
|
||||||
|
expect(sanitizeOutputName("invalid@name!")).toBe("invalid_name_");
|
||||||
|
expect(sanitizeOutputName("has spaces")).toBe("has_spaces");
|
||||||
|
expect(sanitizeOutputName("has.dots")).toBe("has_dots");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle special characters", () => {
|
||||||
|
expect(sanitizeOutputName("$field%name&")).toBe("_field_name_");
|
||||||
|
expect(sanitizeOutputName("field[0]")).toBe("field_0_");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("convertToString", () => {
|
||||||
|
test("should keep strings as-is", () => {
|
||||||
|
expect(convertToString("hello")).toBe("hello");
|
||||||
|
expect(convertToString("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert booleans to strings", () => {
|
||||||
|
expect(convertToString(true)).toBe("true");
|
||||||
|
expect(convertToString(false)).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert numbers to strings", () => {
|
||||||
|
expect(convertToString(42)).toBe("42");
|
||||||
|
expect(convertToString(3.14)).toBe("3.14");
|
||||||
|
expect(convertToString(0)).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert null to empty string", () => {
|
||||||
|
expect(convertToString(null)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should JSON stringify objects", () => {
|
||||||
|
expect(convertToString({ foo: "bar" })).toBe('{"foo":"bar"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should JSON stringify arrays", () => {
|
||||||
|
expect(convertToString([1, 2, 3])).toBe("[1,2,3]");
|
||||||
|
expect(convertToString(["a", "b"])).toBe('["a","b"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle nested structures", () => {
|
||||||
|
const nested = { items: [{ id: 1, name: "test" }] };
|
||||||
|
expect(convertToString(nested)).toBe(
|
||||||
|
'{"items":[{"id":1,"name":"test"}]}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseAndSetStructuredOutputs integration", () => {
|
||||||
|
test("should parse and set simple structured outputs", async () => {
|
||||||
|
await createMockExecutionFile({
|
||||||
|
is_antonly: true,
|
||||||
|
confidence: 0.95,
|
||||||
|
risk: "low",
|
||||||
|
});
|
||||||
|
|
||||||
|
// In a real test, we'd import and call parseAndSetStructuredOutputs
|
||||||
|
// For now, we simulate the behavior
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
const result = messages.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.structured_output).toEqual({
|
||||||
|
is_antonly: true,
|
||||||
|
confidence: 0.95,
|
||||||
|
risk: "low",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle array outputs", async () => {
|
||||||
|
await createMockExecutionFile({
|
||||||
|
affected_areas: ["auth", "database", "api"],
|
||||||
|
severity: "high",
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
const result = messages.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.structured_output?.affected_areas).toEqual([
|
||||||
|
"auth",
|
||||||
|
"database",
|
||||||
|
"api",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle nested objects", async () => {
|
||||||
|
await createMockExecutionFile({
|
||||||
|
analysis: {
|
||||||
|
category: "test",
|
||||||
|
details: { count: 5, passed: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
const result = messages.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.structured_output?.analysis).toEqual({
|
||||||
|
category: "test",
|
||||||
|
details: { count: 5, passed: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle missing structured_output", async () => {
|
||||||
|
await createMockExecutionFile(undefined, true);
|
||||||
|
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
const result = messages.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle empty structured_output", async () => {
|
||||||
|
await createMockExecutionFile({});
|
||||||
|
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
const result = messages.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.structured_output).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle all supported types", async () => {
|
||||||
|
await createMockExecutionFile({
|
||||||
|
string_field: "hello",
|
||||||
|
number_field: 42,
|
||||||
|
boolean_field: true,
|
||||||
|
null_field: null,
|
||||||
|
array_field: [1, 2, 3],
|
||||||
|
object_field: { nested: "value" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
const result = messages.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.structured_output).toMatchObject({
|
||||||
|
string_field: "hello",
|
||||||
|
number_field: 42,
|
||||||
|
boolean_field: true,
|
||||||
|
null_field: null,
|
||||||
|
array_field: [1, 2, 3],
|
||||||
|
object_field: { nested: "value" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("output naming with prefix", () => {
|
||||||
|
test("should apply prefix correctly", () => {
|
||||||
|
const prefix = "CLAUDE_";
|
||||||
|
const key = "is_antonly";
|
||||||
|
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
const outputName = prefix + sanitizedKey;
|
||||||
|
|
||||||
|
expect(outputName).toBe("CLAUDE_is_antonly");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle empty prefix", () => {
|
||||||
|
const prefix = "";
|
||||||
|
const key = "result";
|
||||||
|
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
const outputName = prefix + sanitizedKey;
|
||||||
|
|
||||||
|
expect(outputName).toBe("result");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should sanitize and prefix invalid keys", () => {
|
||||||
|
const prefix = "OUT_";
|
||||||
|
const key = "invalid@key!";
|
||||||
|
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
const outputName = prefix + sanitizedKey;
|
||||||
|
|
||||||
|
expect(outputName).toBe("OUT_invalid_key_");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error scenarios", () => {
|
||||||
|
test("should handle malformed JSON", async () => {
|
||||||
|
await writeFile(TEST_EXECUTION_FILE, "invalid json {");
|
||||||
|
|
||||||
|
let error: Error | undefined;
|
||||||
|
try {
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
JSON.parse(content);
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeDefined();
|
||||||
|
expect(error?.message).toContain("JSON");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle empty execution file", async () => {
|
||||||
|
await writeFile(TEST_EXECUTION_FILE, "[]");
|
||||||
|
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
const result = messages.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle missing result message", async () => {
|
||||||
|
const messages = [
|
||||||
|
{ type: "system", subtype: "init" },
|
||||||
|
{ type: "turn", content: "test" },
|
||||||
|
];
|
||||||
|
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||||
|
|
||||||
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
|
const parsed = JSON.parse(content) as ExecutionMessage[];
|
||||||
|
const result = parsed.find(
|
||||||
|
(m) => m.type === "result" && m.structured_output,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("value truncation in logs", () => {
|
||||||
|
test("should truncate long string values for display", () => {
|
||||||
|
const longValue = "a".repeat(150);
|
||||||
|
const displayValue =
|
||||||
|
longValue.length > 100 ? `${longValue.slice(0, 97)}...` : longValue;
|
||||||
|
|
||||||
|
expect(displayValue).toBe("a".repeat(97) + "...");
|
||||||
|
expect(displayValue.length).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not truncate short values", () => {
|
||||||
|
const shortValue = "short";
|
||||||
|
const displayValue =
|
||||||
|
shortValue.length > 100 ? `${shortValue.slice(0, 97)}...` : shortValue;
|
||||||
|
|
||||||
|
expect(displayValue).toBe("short");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should truncate exactly 100 character values", () => {
|
||||||
|
const value = "a".repeat(100);
|
||||||
|
const displayValue =
|
||||||
|
value.length > 100 ? `${value.slice(0, 97)}...` : value;
|
||||||
|
|
||||||
|
expect(displayValue).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should truncate 101 character values", () => {
|
||||||
|
const value = "a".repeat(101);
|
||||||
|
const displayValue =
|
||||||
|
value.length > 100 ? `${value.slice(0, 97)}...` : value;
|
||||||
|
|
||||||
|
expect(displayValue).toBe("a".repeat(97) + "...");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -80,6 +80,7 @@ jobs:
|
|||||||
| `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 | "" |
|
||||||
| `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 | "" |
|
| `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 | "" |
|
| `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
|
||||||
|
|
||||||
@@ -185,6 +186,80 @@ 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: steps.analyze.outputs.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. **Auto-set Outputs**: Each field automatically becomes a GitHub Action output
|
||||||
|
|
||||||
|
### Type Conversions
|
||||||
|
|
||||||
|
GitHub Actions outputs must be strings. Values are converted automatically:
|
||||||
|
|
||||||
|
- `boolean` → `"true"` or `"false"`
|
||||||
|
- `number` → `"42"` or `"3.14"`
|
||||||
|
- `object/array` → JSON stringified (use `fromJSON()` in workflows to parse)
|
||||||
|
- `null` → `""` (empty string)
|
||||||
|
|
||||||
|
### Output Naming Rules
|
||||||
|
|
||||||
|
- Field names are sanitized: special characters replaced with underscores
|
||||||
|
- Must start with letter or underscore (GitHub Actions requirement)
|
||||||
|
- Reserved names (`conclusion`, `execution_file`) are automatically skipped
|
||||||
|
- Example: `test.result` becomes `test_result`
|
||||||
|
|
||||||
|
### Size Limits
|
||||||
|
|
||||||
|
- Maximum 1MB per output field
|
||||||
|
- Objects/arrays exceeding 1MB are skipped with warnings
|
||||||
|
- Primitive values exceeding 1MB are truncated
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
113
examples/test-failure-analysis.yml
Normal file
113
examples/test-failure-analysis.yml
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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: |
|
||||||
|
steps.detect.outputs.is_flaky == 'true' &&
|
||||||
|
steps.detect.outputs.confidence >= '0.7'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
echo "🔄 Flaky test detected (confidence: ${{ steps.detect.outputs.confidence }})"
|
||||||
|
echo "Summary: ${{ steps.detect.outputs.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: |
|
||||||
|
steps.detect.outputs.is_flaky == 'true' &&
|
||||||
|
steps.detect.outputs.confidence < '0.7'
|
||||||
|
run: |
|
||||||
|
echo "⚠️ Possible flaky test but confidence too low (${{ steps.detect.outputs.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: |
|
||||||
|
pr_number=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" --json number --jq '.[0].number')
|
||||||
|
|
||||||
|
if [ -n "$pr_number" ]; then
|
||||||
|
gh pr comment "$pr_number" --body "$(cat <<EOF
|
||||||
|
## ${{ steps.detect.outputs.is_flaky == 'true' && '🔄 Flaky Test Detected' || '❌ Test Failure' }}
|
||||||
|
|
||||||
|
**Analysis**: ${{ steps.detect.outputs.summary }}
|
||||||
|
**Confidence**: ${{ steps.detect.outputs.confidence }}
|
||||||
|
|
||||||
|
${{ steps.detect.outputs.is_flaky == 'true' && '✅ Automatically retrying the workflow' || '⚠️ This appears to be a real bug - manual intervention needed' }}
|
||||||
|
|
||||||
|
[View workflow run](${{ github.event.workflow_run.html_url }})
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
fi
|
||||||
@@ -149,6 +149,14 @@ export const agentMode: Mode = {
|
|||||||
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add JSON schema if provided
|
||||||
|
const jsonSchemaStr = process.env.JSON_SCHEMA || "";
|
||||||
|
if (jsonSchemaStr) {
|
||||||
|
// CLI validates schema - just escape for safe shell passing
|
||||||
|
const escapedSchema = jsonSchemaStr.replace(/'/g, "'\\''");
|
||||||
|
claudeArgs += ` --json-schema '${escapedSchema}'`;
|
||||||
|
}
|
||||||
|
|
||||||
// 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();
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,14 @@ 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
|
||||||
|
const jsonSchemaStr = process.env.JSON_SCHEMA || "";
|
||||||
|
if (jsonSchemaStr) {
|
||||||
|
// CLI validates schema - just escape for safe shell passing
|
||||||
|
const escapedSchema = jsonSchemaStr.replace(/'/g, "'\\''");
|
||||||
|
claudeArgs += ` --json-schema '${escapedSchema}'`;
|
||||||
|
}
|
||||||
|
|
||||||
// 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}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user