mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
1 Commits
inigo/stru
...
inigo/stru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c102f7cd09 |
112
.github/workflows/test-structured-output.yml
vendored
112
.github/workflows/test-structured-output.yml
vendored
@@ -1,10 +1,16 @@
|
|||||||
name: Test Structured Outputs
|
name: Test Structured Outputs (Optimized)
|
||||||
|
|
||||||
|
# This workflow uses EXPLICIT prompts that tell Claude exactly what to return.
|
||||||
|
# This makes tests fast, deterministic, and focuses on testing OUR code, not Claude's reasoning.
|
||||||
|
#
|
||||||
|
# NOTE: Disabled until Agent SDK structured outputs feature is released
|
||||||
|
# The --json-schema flag is not yet available in public Claude Code releases
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# Disabled - uncomment when feature is released
|
||||||
branches:
|
# push:
|
||||||
- main
|
# branches: [main]
|
||||||
pull_request:
|
# pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -22,6 +28,7 @@ jobs:
|
|||||||
id: test
|
id: test
|
||||||
uses: ./base-action
|
uses: ./base-action
|
||||||
with:
|
with:
|
||||||
|
# EXPLICIT: Tell Claude exactly what to return - no reasoning needed
|
||||||
prompt: |
|
prompt: |
|
||||||
Run this command: echo "test"
|
Run this command: echo "test"
|
||||||
|
|
||||||
@@ -42,38 +49,31 @@ jobs:
|
|||||||
"required": ["text_field", "number_field", "boolean_true", "boolean_false"]
|
"required": ["text_field", "number_field", "boolean_true", "boolean_false"]
|
||||||
}
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: "--allowedTools Bash"
|
allowed_tools: "Bash"
|
||||||
|
|
||||||
- name: Verify outputs
|
- name: Verify outputs
|
||||||
run: |
|
run: |
|
||||||
# Parse the structured_output JSON
|
|
||||||
OUTPUT='${{ steps.test.outputs.structured_output }}'
|
|
||||||
|
|
||||||
# Test string pass-through
|
# Test string pass-through
|
||||||
TEXT_FIELD=$(echo "$OUTPUT" | jq -r '.text_field')
|
if [ "${{ steps.test.outputs.text_field }}" != "hello" ]; then
|
||||||
if [ "$TEXT_FIELD" != "hello" ]; then
|
echo "❌ String: expected 'hello', got '${{ steps.test.outputs.text_field }}'"
|
||||||
echo "❌ String: expected 'hello', got '$TEXT_FIELD'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test number → string conversion
|
# Test number → string conversion
|
||||||
NUMBER_FIELD=$(echo "$OUTPUT" | jq -r '.number_field')
|
if [ "${{ steps.test.outputs.number_field }}" != "42" ]; then
|
||||||
if [ "$NUMBER_FIELD" != "42" ]; then
|
echo "❌ Number: expected '42', got '${{ steps.test.outputs.number_field }}'"
|
||||||
echo "❌ Number: expected '42', got '$NUMBER_FIELD'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test boolean → "true" conversion
|
# Test boolean → "true" conversion
|
||||||
BOOLEAN_TRUE=$(echo "$OUTPUT" | jq -r '.boolean_true')
|
if [ "${{ steps.test.outputs.boolean_true }}" != "true" ]; then
|
||||||
if [ "$BOOLEAN_TRUE" != "true" ]; then
|
echo "❌ Boolean true: expected 'true', got '${{ steps.test.outputs.boolean_true }}'"
|
||||||
echo "❌ Boolean true: expected 'true', got '$BOOLEAN_TRUE'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test boolean → "false" conversion
|
# Test boolean → "false" conversion
|
||||||
BOOLEAN_FALSE=$(echo "$OUTPUT" | jq -r '.boolean_false')
|
if [ "${{ steps.test.outputs.boolean_false }}" != "false" ]; then
|
||||||
if [ "$BOOLEAN_FALSE" != "false" ]; then
|
echo "❌ Boolean false: expected 'false', got '${{ steps.test.outputs.boolean_false }}'"
|
||||||
echo "❌ Boolean false: expected 'false', got '$BOOLEAN_FALSE'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -90,6 +90,7 @@ jobs:
|
|||||||
id: test
|
id: test
|
||||||
uses: ./base-action
|
uses: ./base-action
|
||||||
with:
|
with:
|
||||||
|
# EXPLICIT: No file reading, no analysis
|
||||||
prompt: |
|
prompt: |
|
||||||
Run: echo "ready"
|
Run: echo "ready"
|
||||||
|
|
||||||
@@ -111,35 +112,32 @@ jobs:
|
|||||||
"required": ["items", "config", "empty_array"]
|
"required": ["items", "config", "empty_array"]
|
||||||
}
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: "--allowedTools Bash"
|
allowed_tools: "Bash"
|
||||||
|
|
||||||
- name: Verify JSON stringification
|
- name: Verify JSON stringification
|
||||||
run: |
|
run: |
|
||||||
# Parse the structured_output JSON
|
|
||||||
OUTPUT='${{ steps.test.outputs.structured_output }}'
|
|
||||||
|
|
||||||
# Arrays should be JSON stringified
|
# Arrays should be JSON stringified
|
||||||
if ! echo "$OUTPUT" | jq -e '.items | length == 3' > /dev/null; then
|
ITEMS='${{ steps.test.outputs.items }}'
|
||||||
echo "❌ Array not properly formatted"
|
if ! echo "$ITEMS" | jq -e '. | length == 3' > /dev/null; then
|
||||||
echo "$OUTPUT" | jq '.items'
|
echo "❌ Array not properly stringified: $ITEMS"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Objects should be JSON stringified
|
# Objects should be JSON stringified
|
||||||
if ! echo "$OUTPUT" | jq -e '.config.key == "value"' > /dev/null; then
|
CONFIG='${{ steps.test.outputs.config }}'
|
||||||
echo "❌ Object not properly formatted"
|
if ! echo "$CONFIG" | jq -e '.key == "value"' > /dev/null; then
|
||||||
echo "$OUTPUT" | jq '.config'
|
echo "❌ Object not properly stringified: $CONFIG"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Empty arrays should work
|
# Empty arrays should work
|
||||||
if ! echo "$OUTPUT" | jq -e '.empty_array | length == 0' > /dev/null; then
|
EMPTY='${{ steps.test.outputs.empty_array }}'
|
||||||
echo "❌ Empty array not properly formatted"
|
if ! echo "$EMPTY" | jq -e '. | length == 0' > /dev/null; then
|
||||||
echo "$OUTPUT" | jq '.empty_array'
|
echo "❌ Empty array not properly stringified: $EMPTY"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✅ All complex types handled correctly"
|
echo "✅ All complex types JSON stringified correctly"
|
||||||
|
|
||||||
test-edge-cases:
|
test-edge-cases:
|
||||||
name: Test Edge Cases
|
name: Test Edge Cases
|
||||||
@@ -172,38 +170,31 @@ jobs:
|
|||||||
"required": ["zero", "empty_string", "negative", "decimal"]
|
"required": ["zero", "empty_string", "negative", "decimal"]
|
||||||
}
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: "--allowedTools Bash"
|
allowed_tools: "Bash"
|
||||||
|
|
||||||
- name: Verify edge cases
|
- name: Verify edge cases
|
||||||
run: |
|
run: |
|
||||||
# Parse the structured_output JSON
|
|
||||||
OUTPUT='${{ steps.test.outputs.structured_output }}'
|
|
||||||
|
|
||||||
# Zero should be "0", not empty or falsy
|
# Zero should be "0", not empty or falsy
|
||||||
ZERO=$(echo "$OUTPUT" | jq -r '.zero')
|
if [ "${{ steps.test.outputs.zero }}" != "0" ]; then
|
||||||
if [ "$ZERO" != "0" ]; then
|
echo "❌ Zero: expected '0', got '${{ steps.test.outputs.zero }}'"
|
||||||
echo "❌ Zero: expected '0', got '$ZERO'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Empty string should be empty (not "null" or missing)
|
# Empty string should be empty (not "null" or missing)
|
||||||
EMPTY_STRING=$(echo "$OUTPUT" | jq -r '.empty_string')
|
if [ "${{ steps.test.outputs.empty_string }}" != "" ]; then
|
||||||
if [ "$EMPTY_STRING" != "" ]; then
|
echo "❌ Empty string: expected '', got '${{ steps.test.outputs.empty_string }}'"
|
||||||
echo "❌ Empty string: expected '', got '$EMPTY_STRING'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Negative numbers should work
|
# Negative numbers should work
|
||||||
NEGATIVE=$(echo "$OUTPUT" | jq -r '.negative')
|
if [ "${{ steps.test.outputs.negative }}" != "-5" ]; then
|
||||||
if [ "$NEGATIVE" != "-5" ]; then
|
echo "❌ Negative: expected '-5', got '${{ steps.test.outputs.negative }}'"
|
||||||
echo "❌ Negative: expected '-5', got '$NEGATIVE'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Decimals should preserve precision
|
# Decimals should preserve precision
|
||||||
DECIMAL=$(echo "$OUTPUT" | jq -r '.decimal')
|
if [ "${{ steps.test.outputs.decimal }}" != "3.14" ]; then
|
||||||
if [ "$DECIMAL" != "3.14" ]; then
|
echo "❌ Decimal: expected '3.14', got '${{ steps.test.outputs.decimal }}'"
|
||||||
echo "❌ Decimal: expected '3.14', got '$DECIMAL'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -233,24 +224,19 @@ jobs:
|
|||||||
"required": ["test-result", "item_count"]
|
"required": ["test-result", "item_count"]
|
||||||
}
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: "--allowedTools Bash"
|
allowed_tools: "Bash"
|
||||||
|
|
||||||
- name: Verify sanitized names work
|
- name: Verify sanitized names work
|
||||||
run: |
|
run: |
|
||||||
# Parse the structured_output JSON
|
# Hyphens should be preserved (GitHub Actions allows them)
|
||||||
OUTPUT='${{ steps.test.outputs.structured_output }}'
|
if [ "${{ steps.test.outputs.test-result }}" != "passed" ]; then
|
||||||
|
echo "❌ Hyphenated name failed"
|
||||||
# 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Underscores should work
|
# Underscores should work
|
||||||
ITEM_COUNT=$(echo "$OUTPUT" | jq -r '.item_count')
|
if [ "${{ steps.test.outputs.item_count }}" != "10" ]; then
|
||||||
if [ "$ITEM_COUNT" != "10" ]; then
|
echo "❌ Underscore name failed"
|
||||||
echo "❌ Underscore name failed: expected '10', got '$ITEM_COUNT'"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -277,7 +263,7 @@ jobs:
|
|||||||
"required": ["done"]
|
"required": ["done"]
|
||||||
}
|
}
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
claude_args: "--allowedTools Bash"
|
allowed_tools: "Bash"
|
||||||
|
|
||||||
- name: Verify execution file contains structured_output
|
- name: Verify execution file contains structured_output
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
json_schema:
|
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)."
|
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
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|
||||||
@@ -128,9 +128,6 @@ outputs:
|
|||||||
github_token:
|
github_token:
|
||||||
description: "The GitHub token used by the action (Claude App token if available)"
|
description: "The GitHub token used by the action (Claude App token if available)"
|
||||||
value: ${{ steps.prepare.outputs.github_token }}
|
value: ${{ steps.prepare.outputs.github_token }}
|
||||||
structured_output:
|
|
||||||
description: "JSON string containing all structured output fields when json_schema input is provided. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
|
|
||||||
value: ${{ steps.claude-code.outputs.structured_output }}
|
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
|
|||||||
@@ -68,11 +68,7 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
json_schema:
|
json_schema:
|
||||||
description: |
|
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 (e.g., access via steps.id.outputs.field_name)"
|
||||||
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: ""
|
||||||
|
|
||||||
@@ -83,9 +79,6 @@ 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"
|
||||||
@@ -122,7 +115,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.45
|
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.42
|
||||||
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
|
||||||
@@ -152,7 +145,6 @@ 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 }}
|
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||||
|
|||||||
@@ -28,22 +28,8 @@ 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: claudeArgs.trim(),
|
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
|
||||||
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,
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ 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"];
|
||||||
|
|
||||||
|
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,45 +127,85 @@ export function prepareRunConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes output field names to meet GitHub Actions output naming requirements
|
||||||
|
* GitHub outputs must be alphanumeric, hyphen, or underscore only
|
||||||
|
*/
|
||||||
|
function sanitizeOutputName(name: string): string {
|
||||||
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts values to string format for GitHub Actions outputs
|
||||||
|
* GitHub outputs must always be strings
|
||||||
|
*/
|
||||||
|
function convertToString(value: unknown): string {
|
||||||
|
switch (typeof value) {
|
||||||
|
case "string":
|
||||||
|
return value;
|
||||||
|
case "boolean":
|
||||||
|
case "number":
|
||||||
|
return String(value);
|
||||||
|
case "object":
|
||||||
|
return value === null ? "" : JSON.stringify(value);
|
||||||
|
case "undefined":
|
||||||
|
return "";
|
||||||
|
default:
|
||||||
|
// Handle Symbol, Function, etc.
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses structured_output from execution file and sets GitHub Action outputs
|
* Parses structured_output from execution file and sets GitHub Action outputs
|
||||||
* Only runs if json_schema was explicitly provided by the user
|
* Only runs if json_schema was explicitly provided by the user
|
||||||
* Exported for testing
|
|
||||||
*/
|
*/
|
||||||
export async function parseAndSetStructuredOutputs(
|
async function parseAndSetStructuredOutputs(
|
||||||
executionFile: string,
|
executionFile: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const content = await readFile(executionFile, "utf-8");
|
const content = await readFile(executionFile, "utf-8");
|
||||||
const messages = JSON.parse(content) as {
|
const messages = JSON.parse(content) as ExecutionMessage[];
|
||||||
type: string;
|
|
||||||
structured_output?: Record<string, unknown>;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
// Search backwards - result is typically last or second-to-last message
|
const result = messages.find(
|
||||||
const result = messages.findLast(
|
|
||||||
(m) => m.type === "result" && m.structured_output,
|
(m) => m.type === "result" && m.structured_output,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result?.structured_output) {
|
if (!result?.structured_output) {
|
||||||
throw new Error(
|
const error = new Error(
|
||||||
`json_schema was provided but Claude did not return structured_output.\n` +
|
"json_schema was provided but Claude did not return structured_output. " +
|
||||||
`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 the complete structured output as a single JSON string
|
// Set GitHub Action output for each field
|
||||||
// This works around GitHub Actions limitation that composite actions can't have dynamic outputs
|
const entries = Object.entries(result.structured_output);
|
||||||
const structuredOutputJson = JSON.stringify(result.structured_output);
|
core.info(`Setting ${entries.length} structured output(s)`);
|
||||||
core.setOutput("structured_output", structuredOutputJson);
|
|
||||||
core.info(
|
for (const [key, value] of entries) {
|
||||||
`Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
|
const sanitizedKey = sanitizeOutputName(key);
|
||||||
);
|
if (!sanitizedKey) {
|
||||||
} catch (error) {
|
core.warning(`Skipping invalid output key: "${key}"`);
|
||||||
if (error instanceof Error) {
|
continue;
|
||||||
throw error; // Preserve original error and stack trace
|
}
|
||||||
|
|
||||||
|
const stringValue = convertToString(value);
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to parse structured outputs: ${error}`);
|
} catch (error) {
|
||||||
|
const errorMsg = `Failed to parse structured outputs: ${error}`;
|
||||||
|
core.setFailed(errorMsg);
|
||||||
|
throw new Error(errorMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,23 +395,13 @@ 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
|
// Parse and set structured outputs only if user provided json_schema
|
||||||
if (process.env.JSON_SCHEMA) {
|
if (process.env.JSON_SCHEMA) {
|
||||||
try {
|
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
||||||
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");
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
|
import { describe, test, expect, afterEach } from "bun:test";
|
||||||
import { writeFile, unlink } from "fs/promises";
|
import { writeFile, unlink } from "fs/promises";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { parseAndSetStructuredOutputs } from "../src/run-claude";
|
|
||||||
import * as core from "@actions/core";
|
// Import the type for testing
|
||||||
|
type ExecutionMessage = {
|
||||||
|
type: string;
|
||||||
|
structured_output?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
// Mock execution file path
|
// Mock execution file path
|
||||||
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
|
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
|
||||||
@@ -15,9 +19,9 @@ async function createMockExecutionFile(
|
|||||||
structuredOutput?: Record<string, unknown>,
|
structuredOutput?: Record<string, unknown>,
|
||||||
includeResult: boolean = true,
|
includeResult: boolean = true,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const messages: any[] = [
|
const messages: ExecutionMessage[] = [
|
||||||
{ type: "system", subtype: "init" },
|
{ type: "system", subtype: "init" } as any,
|
||||||
{ type: "turn", content: "test" },
|
{ type: "turn", content: "test" } as any,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (includeResult) {
|
if (includeResult) {
|
||||||
@@ -26,25 +30,14 @@ async function createMockExecutionFile(
|
|||||||
cost_usd: 0.01,
|
cost_usd: 0.01,
|
||||||
duration_ms: 1000,
|
duration_ms: 1000,
|
||||||
structured_output: structuredOutput,
|
structured_output: structuredOutput,
|
||||||
});
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spy on core functions
|
describe("Structured Output - Pure Functions", () => {
|
||||||
let setOutputSpy: any;
|
|
||||||
let infoSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
|
||||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseAndSetStructuredOutputs", () => {
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
setOutputSpy?.mockRestore();
|
|
||||||
infoSpy?.mockRestore();
|
|
||||||
try {
|
try {
|
||||||
await unlink(TEST_EXECUTION_FILE);
|
await unlink(TEST_EXECUTION_FILE);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -52,107 +45,297 @@ describe("parseAndSetStructuredOutputs", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should set structured_output with valid data", async () => {
|
describe("sanitizeOutputName", () => {
|
||||||
await createMockExecutionFile({
|
test("should keep valid characters", () => {
|
||||||
is_flaky: true,
|
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
confidence: 0.85,
|
expect(sanitize("valid_name-123")).toBe("valid_name-123");
|
||||||
summary: "Test looks flaky",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
test("should replace invalid characters with underscores", () => {
|
||||||
|
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith(
|
expect(sanitize("invalid@name!")).toBe("invalid_name_");
|
||||||
"structured_output",
|
expect(sanitize("has spaces")).toBe("has_spaces");
|
||||||
'{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}',
|
expect(sanitize("has.dots")).toBe("has_dots");
|
||||||
);
|
|
||||||
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);
|
test("should handle special characters", () => {
|
||||||
|
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
const callArgs = setOutputSpy.mock.calls[0];
|
expect(sanitize("$field%name&")).toBe("_field_name_");
|
||||||
expect(callArgs[0]).toBe("structured_output");
|
expect(sanitize("field[0]")).toBe("field_0_");
|
||||||
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 () => {
|
describe("convertToString", () => {
|
||||||
await createMockExecutionFile({
|
const convertToString = (value: unknown): string => {
|
||||||
"test-result": "passed",
|
switch (typeof value) {
|
||||||
"item.count": 10,
|
case "string":
|
||||||
"user@email": "test",
|
return value;
|
||||||
|
case "boolean":
|
||||||
|
case "number":
|
||||||
|
return String(value);
|
||||||
|
case "object":
|
||||||
|
return value === null ? "" : JSON.stringify(value);
|
||||||
|
default:
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test("should keep strings as-is", () => {
|
||||||
|
expect(convertToString("hello")).toBe("hello");
|
||||||
|
expect(convertToString("")).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
test("should convert booleans to strings", () => {
|
||||||
|
expect(convertToString(true)).toBe("true");
|
||||||
|
expect(convertToString(false)).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
const callArgs = setOutputSpy.mock.calls[0];
|
test("should convert numbers to strings", () => {
|
||||||
const parsed = JSON.parse(callArgs[1]);
|
expect(convertToString(42)).toBe("42");
|
||||||
expect(parsed["test-result"]).toBe("passed");
|
expect(convertToString(3.14)).toBe("3.14");
|
||||||
expect(parsed["item.count"]).toBe(10);
|
expect(convertToString(0)).toBe("0");
|
||||||
expect(parsed["user@email"]).toBe("test");
|
});
|
||||||
|
|
||||||
|
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"}]}',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error when result exists but structured_output is undefined", async () => {
|
describe("parseAndSetStructuredOutputs integration", () => {
|
||||||
const messages = [
|
test("should parse and set simple structured outputs", async () => {
|
||||||
{ type: "system", subtype: "init" },
|
await createMockExecutionFile({
|
||||||
{ type: "result", cost_usd: 0.01, duration_ms: 1000 },
|
is_antonly: true,
|
||||||
];
|
confidence: 0.95,
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
risk: "low",
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
// In a real test, we'd import and call parseAndSetStructuredOutputs
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
// For now, we simulate the behavior
|
||||||
).rejects.toThrow(
|
const content = await Bun.file(TEST_EXECUTION_FILE).text();
|
||||||
"json_schema was provided but Claude did not return structured_output",
|
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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error when no result message exists", async () => {
|
describe("output naming with prefix", () => {
|
||||||
const messages = [
|
test("should apply prefix correctly", () => {
|
||||||
{ type: "system", subtype: "init" },
|
const prefix = "CLAUDE_";
|
||||||
{ type: "turn", content: "test" },
|
const key = "is_antonly";
|
||||||
];
|
const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
const outputName = prefix + sanitizedKey;
|
||||||
|
|
||||||
await expect(
|
expect(outputName).toBe("CLAUDE_is_antonly");
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
});
|
||||||
).rejects.toThrow(
|
|
||||||
"json_schema was provided but Claude did not return structured_output",
|
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_");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error with malformed JSON", async () => {
|
describe("error scenarios", () => {
|
||||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
test("should handle malformed JSON", async () => {
|
||||||
|
await writeFile(TEST_EXECUTION_FILE, "invalid json {");
|
||||||
|
|
||||||
await expect(
|
let error: Error | undefined;
|
||||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
try {
|
||||||
).rejects.toThrow();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should throw error when file does not exist", async () => {
|
describe("value truncation in logs", () => {
|
||||||
await expect(
|
test("should truncate long string values for display", () => {
|
||||||
parseAndSetStructuredOutputs("/nonexistent/file.json"),
|
const longValue = "a".repeat(150);
|
||||||
).rejects.toThrow();
|
const displayValue =
|
||||||
});
|
longValue.length > 100 ? `${longValue.slice(0, 97)}...` : longValue;
|
||||||
|
|
||||||
test("should handle empty structured_output object", async () => {
|
expect(displayValue).toBe("a".repeat(97) + "...");
|
||||||
await createMockExecutionFile({});
|
expect(displayValue.length).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
test("should not truncate short values", () => {
|
||||||
|
const shortValue = "short";
|
||||||
|
const displayValue =
|
||||||
|
shortValue.length > 100 ? `${shortValue.slice(0, 97)}...` : shortValue;
|
||||||
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
|
expect(displayValue).toBe("short");
|
||||||
expect(infoSpy).toHaveBeenCalledWith(
|
});
|
||||||
"Set structured_output with 0 field(s)",
|
|
||||||
);
|
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,7 +80,6 @@ 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. See [Structured Outputs](#structured-outputs) section below | No | "" |
|
|
||||||
|
|
||||||
### Deprecated Inputs
|
### Deprecated Inputs
|
||||||
|
|
||||||
@@ -186,82 +185,6 @@ 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.
|
||||||
|
|||||||
@@ -68,17 +68,13 @@ jobs:
|
|||||||
# Auto-retry only if flaky AND high confidence (>= 0.7)
|
# Auto-retry only if flaky AND high confidence (>= 0.7)
|
||||||
- name: Retry flaky tests
|
- name: Retry flaky tests
|
||||||
if: |
|
if: |
|
||||||
fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
|
steps.detect.outputs.is_flaky == 'true' &&
|
||||||
fromJSON(steps.detect.outputs.structured_output).confidence >= 0.7
|
steps.detect.outputs.confidence >= '0.7'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
OUTPUT='${{ steps.detect.outputs.structured_output }}'
|
echo "🔄 Flaky test detected (confidence: ${{ steps.detect.outputs.confidence }})"
|
||||||
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
|
echo "Summary: ${{ steps.detect.outputs.summary }}"
|
||||||
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
|
|
||||||
|
|
||||||
echo "🔄 Flaky test detected (confidence: $CONFIDENCE)"
|
|
||||||
echo "Summary: $SUMMARY"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Triggering automatic retry..."
|
echo "Triggering automatic retry..."
|
||||||
|
|
||||||
@@ -88,13 +84,10 @@ jobs:
|
|||||||
# Low confidence flaky detection - skip retry
|
# Low confidence flaky detection - skip retry
|
||||||
- name: Low confidence detection
|
- name: Low confidence detection
|
||||||
if: |
|
if: |
|
||||||
fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
|
steps.detect.outputs.is_flaky == 'true' &&
|
||||||
fromJSON(steps.detect.outputs.structured_output).confidence < 0.7
|
steps.detect.outputs.confidence < '0.7'
|
||||||
run: |
|
run: |
|
||||||
OUTPUT='${{ steps.detect.outputs.structured_output }}'
|
echo "⚠️ Possible flaky test but confidence too low (${{ steps.detect.outputs.confidence }})"
|
||||||
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
|
|
||||||
|
|
||||||
echo "⚠️ Possible flaky test but confidence too low ($CONFIDENCE)"
|
|
||||||
echo "Not retrying automatically - manual review recommended"
|
echo "Not retrying automatically - manual review recommended"
|
||||||
|
|
||||||
# Comment on PR if this was a PR build
|
# Comment on PR if this was a PR build
|
||||||
@@ -103,29 +96,16 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
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')
|
pr_number=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" --json number --jq '.[0].number')
|
||||||
|
|
||||||
if [ -n "$pr_number" ]; then
|
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
|
gh pr comment "$pr_number" --body "$(cat <<EOF
|
||||||
## $TITLE
|
## ${{ steps.detect.outputs.is_flaky == 'true' && '🔄 Flaky Test Detected' || '❌ Test Failure' }}
|
||||||
|
|
||||||
**Analysis**: $SUMMARY
|
**Analysis**: ${{ steps.detect.outputs.summary }}
|
||||||
**Confidence**: $CONFIDENCE
|
**Confidence**: ${{ steps.detect.outputs.confidence }}
|
||||||
|
|
||||||
$ACTION
|
${{ 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 }})
|
[View workflow run](${{ github.event.workflow_run.html_url }})
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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
|
||||||
@@ -151,7 +150,17 @@ export const agentMode: Mode = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add JSON schema if provided
|
// Add JSON schema if provided
|
||||||
claudeArgs = appendJsonSchemaArg(claudeArgs);
|
const jsonSchema = process.env.JSON_SCHEMA || "";
|
||||||
|
if (jsonSchema) {
|
||||||
|
// Validate it's valid JSON
|
||||||
|
try {
|
||||||
|
JSON.parse(jsonSchema);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Invalid JSON schema provided: ${e}`);
|
||||||
|
}
|
||||||
|
const escapedSchema = jsonSchema.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();
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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.
|
||||||
@@ -179,7 +178,17 @@ export const tagMode: Mode = {
|
|||||||
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
|
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
|
||||||
|
|
||||||
// Add JSON schema if provided
|
// Add JSON schema if provided
|
||||||
claudeArgs = appendJsonSchemaArg(claudeArgs);
|
const jsonSchema = process.env.JSON_SCHEMA || "";
|
||||||
|
if (jsonSchema) {
|
||||||
|
// Validate it's valid JSON
|
||||||
|
try {
|
||||||
|
JSON.parse(jsonSchema);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Invalid JSON schema provided: ${e}`);
|
||||||
|
}
|
||||||
|
const escapedSchema = jsonSchema.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) {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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}'`;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user