Compare commits

..

11 Commits

Author SHA1 Message Date
inigo
606f6af1c6 docs: update structured outputs documentation 2025-11-18 15:36:45 -08:00
inigo
6bc261bb35 feat: add structured output support 2025-11-18 15:22:15 -08:00
inigo
84265a4271 update 2025-11-18 14:37:48 -08:00
inigo
9d3bab5bc7 test: add proper test coverage for parseAndSetStructuredOutputs
Fixed test coverage gap where tests were only parsing JSON manually
without actually invoking the parseAndSetStructuredOutputs function.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

All tests pass (462 tests).

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

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

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

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

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

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

Fixes #683 test failures

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

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

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

All tests passing, types checked, code formatted.

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 11:48:03 -08:00
12 changed files with 353 additions and 435 deletions

View File

@@ -1,16 +1,10 @@
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
name: Test Structured Outputs
on:
# Disabled - uncomment when feature is released
# push:
# branches: [main]
# pull_request:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
@@ -28,7 +22,6 @@ jobs:
id: test
uses: ./base-action
with:
# EXPLICIT: Tell Claude exactly what to return - no reasoning needed
prompt: |
Run this command: echo "test"
@@ -49,31 +42,38 @@ jobs:
"required": ["text_field", "number_field", "boolean_true", "boolean_false"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
claude_args: "--allowedTools Bash"
- name: Verify outputs
run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Test string pass-through
if [ "${{ steps.test.outputs.text_field }}" != "hello" ]; then
echo "❌ String: expected 'hello', got '${{ steps.test.outputs.text_field }}'"
TEXT_FIELD=$(echo "$OUTPUT" | jq -r '.text_field')
if [ "$TEXT_FIELD" != "hello" ]; then
echo "❌ String: expected 'hello', got '$TEXT_FIELD'"
exit 1
fi
# Test number → string conversion
if [ "${{ steps.test.outputs.number_field }}" != "42" ]; then
echo "❌ Number: expected '42', got '${{ steps.test.outputs.number_field }}'"
NUMBER_FIELD=$(echo "$OUTPUT" | jq -r '.number_field')
if [ "$NUMBER_FIELD" != "42" ]; then
echo "❌ Number: expected '42', got '$NUMBER_FIELD'"
exit 1
fi
# Test boolean → "true" conversion
if [ "${{ steps.test.outputs.boolean_true }}" != "true" ]; then
echo "❌ Boolean true: expected 'true', got '${{ steps.test.outputs.boolean_true }}'"
BOOLEAN_TRUE=$(echo "$OUTPUT" | jq -r '.boolean_true')
if [ "$BOOLEAN_TRUE" != "true" ]; then
echo "❌ Boolean true: expected 'true', got '$BOOLEAN_TRUE'"
exit 1
fi
# Test boolean → "false" conversion
if [ "${{ steps.test.outputs.boolean_false }}" != "false" ]; then
echo "❌ Boolean false: expected 'false', got '${{ steps.test.outputs.boolean_false }}'"
BOOLEAN_FALSE=$(echo "$OUTPUT" | jq -r '.boolean_false')
if [ "$BOOLEAN_FALSE" != "false" ]; then
echo "❌ Boolean false: expected 'false', got '$BOOLEAN_FALSE'"
exit 1
fi
@@ -90,7 +90,6 @@ jobs:
id: test
uses: ./base-action
with:
# EXPLICIT: No file reading, no analysis
prompt: |
Run: echo "ready"
@@ -112,32 +111,35 @@ jobs:
"required": ["items", "config", "empty_array"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
claude_args: "--allowedTools Bash"
- name: Verify JSON stringification
run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# 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"
if ! echo "$OUTPUT" | jq -e '.items | length == 3' > /dev/null; then
echo "❌ Array not properly formatted"
echo "$OUTPUT" | jq '.items'
exit 1
fi
# Objects should be JSON stringified
CONFIG='${{ steps.test.outputs.config }}'
if ! echo "$CONFIG" | jq -e '.key == "value"' > /dev/null; then
echo "❌ Object not properly stringified: $CONFIG"
if ! echo "$OUTPUT" | jq -e '.config.key == "value"' > /dev/null; then
echo "❌ Object not properly formatted"
echo "$OUTPUT" | jq '.config'
exit 1
fi
# Empty arrays should work
EMPTY='${{ steps.test.outputs.empty_array }}'
if ! echo "$EMPTY" | jq -e '. | length == 0' > /dev/null; then
echo "❌ Empty array not properly stringified: $EMPTY"
if ! echo "$OUTPUT" | jq -e '.empty_array | length == 0' > /dev/null; then
echo "❌ Empty array not properly formatted"
echo "$OUTPUT" | jq '.empty_array'
exit 1
fi
echo "✅ All complex types JSON stringified correctly"
echo "✅ All complex types handled correctly"
test-edge-cases:
name: Test Edge Cases
@@ -170,31 +172,38 @@ jobs:
"required": ["zero", "empty_string", "negative", "decimal"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
claude_args: "--allowedTools Bash"
- name: Verify edge cases
run: |
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Zero should be "0", not empty or falsy
if [ "${{ steps.test.outputs.zero }}" != "0" ]; then
echo "❌ Zero: expected '0', got '${{ steps.test.outputs.zero }}'"
ZERO=$(echo "$OUTPUT" | jq -r '.zero')
if [ "$ZERO" != "0" ]; then
echo "❌ Zero: expected '0', got '$ZERO'"
exit 1
fi
# Empty string should be empty (not "null" or missing)
if [ "${{ steps.test.outputs.empty_string }}" != "" ]; then
echo "❌ Empty string: expected '', got '${{ steps.test.outputs.empty_string }}'"
EMPTY_STRING=$(echo "$OUTPUT" | jq -r '.empty_string')
if [ "$EMPTY_STRING" != "" ]; then
echo "❌ Empty string: expected '', got '$EMPTY_STRING'"
exit 1
fi
# Negative numbers should work
if [ "${{ steps.test.outputs.negative }}" != "-5" ]; then
echo "❌ Negative: expected '-5', got '${{ steps.test.outputs.negative }}'"
NEGATIVE=$(echo "$OUTPUT" | jq -r '.negative')
if [ "$NEGATIVE" != "-5" ]; then
echo "❌ Negative: expected '-5', got '$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 }}'"
DECIMAL=$(echo "$OUTPUT" | jq -r '.decimal')
if [ "$DECIMAL" != "3.14" ]; then
echo "❌ Decimal: expected '3.14', got '$DECIMAL'"
exit 1
fi
@@ -224,19 +233,24 @@ jobs:
"required": ["test-result", "item_count"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
claude_args: "--allowedTools 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"
# Parse the structured_output JSON
OUTPUT='${{ steps.test.outputs.structured_output }}'
# Hyphens should be preserved in the JSON
TEST_RESULT=$(echo "$OUTPUT" | jq -r '.["test-result"]')
if [ "$TEST_RESULT" != "passed" ]; then
echo "❌ Hyphenated name failed: expected 'passed', got '$TEST_RESULT'"
exit 1
fi
# Underscores should work
if [ "${{ steps.test.outputs.item_count }}" != "10" ]; then
echo "❌ Underscore name failed"
ITEM_COUNT=$(echo "$OUTPUT" | jq -r '.item_count')
if [ "$ITEM_COUNT" != "10" ]; then
echo "❌ Underscore name failed: expected '10', got '$ITEM_COUNT'"
exit 1
fi
@@ -263,7 +277,7 @@ jobs:
"required": ["done"]
}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "Bash"
claude_args: "--allowedTools Bash"
- name: Verify execution file contains structured_output
run: |

View File

@@ -13,6 +13,7 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
- 💬 **PR/Issue Integration**: Works seamlessly with GitHub comments and PR reviews
- 🛠️ **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
- 📊 **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)
- ⚙️ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK

View File

@@ -114,7 +114,7 @@ inputs:
required: false
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."
description: "JSON schema for structured output validation. When provided, Claude will return validated JSON matching this schema. All fields are available in the structured_output output as a JSON string (use fromJSON() or jq to access fields)."
required: false
default: ""
@@ -128,6 +128,9 @@ outputs:
github_token:
description: "The GitHub token used by the action (Claude App token if available)"
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:
using: "composite"

View File

@@ -68,7 +68,11 @@ inputs:
required: false
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 (e.g., access via steps.id.outputs.field_name)"
description: |
JSON schema for structured output validation. Claude must return JSON matching this schema
or the action will fail. All fields are returned in a single structured_output JSON string.
Access outputs via: fromJSON(steps.<step-id>.outputs.structured_output).<field_name>
required: false
default: ""
@@ -79,6 +83,9 @@ outputs:
execution_file:
description: "Path to the JSON file containing Claude Code execution log"
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:
using: "composite"
@@ -115,7 +122,7 @@ runs:
run: |
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.42
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.45
else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
# Add the directory containing the custom executable to PATH
@@ -145,6 +152,7 @@ runs:
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
INPUT_PLUGINS: ${{ inputs.plugins }}
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
JSON_SCHEMA: ${{ inputs.json_schema }}
# Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}

View File

@@ -28,8 +28,22 @@ async function run() {
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, {
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
claudeArgs: claudeArgs.trim(),
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS,

View File

@@ -12,11 +12,6 @@ const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
type ExecutionMessage = {
type: string;
structured_output?: Record<string, unknown>;
};
/**
* 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
@@ -127,85 +122,45 @@ 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
* Only runs if json_schema was explicitly provided by the user
* Exported for testing
*/
async function parseAndSetStructuredOutputs(
export async function parseAndSetStructuredOutputs(
executionFile: string,
): Promise<void> {
try {
const content = await readFile(executionFile, "utf-8");
const messages = JSON.parse(content) as ExecutionMessage[];
const messages = JSON.parse(content) as {
type: string;
structured_output?: Record<string, unknown>;
}[];
const result = messages.find(
// Search backwards - result is typically last or second-to-last message
const result = messages.findLast(
(m) => m.type === "result" && m.structured_output,
);
if (!result?.structured_output) {
const error = new Error(
"json_schema was provided but Claude did not return structured_output. " +
"The schema may be invalid or Claude failed to call the StructuredOutput tool.",
throw new Error(
`json_schema was provided but Claude did not return structured_output.\n` +
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
);
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) {
const sanitizedKey = sanitizeOutputName(key);
if (!sanitizedKey) {
core.warning(`Skipping invalid output key: "${key}"`);
continue;
}
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}`);
}
// Set the complete structured output as a single JSON string
// This works around GitHub Actions limitation that composite actions can't have dynamic outputs
const structuredOutputJson = JSON.stringify(result.structured_output);
core.setOutput("structured_output", structuredOutputJson);
core.info(
`Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
);
} catch (error) {
const errorMsg = `Failed to parse structured outputs: ${error}`;
core.setFailed(errorMsg);
throw new Error(errorMsg);
if (error instanceof Error) {
throw error; // Preserve original error and stack trace
}
throw new Error(`Failed to parse structured outputs: ${error}`);
}
}
@@ -395,13 +350,23 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
core.warning(`Failed to process output for execution metrics: ${e}`);
}
core.setOutput("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE);
// Parse and set structured outputs only if user provided json_schema
if (process.env.JSON_SCHEMA) {
await parseAndSetStructuredOutputs(EXECUTION_FILE);
try {
await parseAndSetStructuredOutputs(EXECUTION_FILE);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
core.setFailed(errorMessage);
core.setOutput("conclusion", "failure");
process.exit(1);
}
}
// Set conclusion to success if we reached here
core.setOutput("conclusion", "success");
} else {
core.setOutput("conclusion", "failure");

View File

@@ -1,15 +1,11 @@
#!/usr/bin/env bun
import { describe, test, expect, afterEach } from "bun:test";
import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
import { writeFile, unlink } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
// Import the type for testing
type ExecutionMessage = {
type: string;
structured_output?: Record<string, unknown>;
};
import { parseAndSetStructuredOutputs } from "../src/run-claude";
import * as core from "@actions/core";
// Mock execution file path
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
@@ -19,9 +15,9 @@ 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,
const messages: any[] = [
{ type: "system", subtype: "init" },
{ type: "turn", content: "test" },
];
if (includeResult) {
@@ -30,14 +26,25 @@ async function createMockExecutionFile(
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", () => {
// Spy on core functions
let setOutputSpy: any;
let infoSpy: any;
beforeEach(() => {
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
infoSpy = spyOn(core, "info").mockImplementation(() => {});
});
describe("parseAndSetStructuredOutputs", () => {
afterEach(async () => {
setOutputSpy?.mockRestore();
infoSpy?.mockRestore();
try {
await unlink(TEST_EXECUTION_FILE);
} catch {
@@ -45,297 +52,107 @@ describe("Structured Output - Pure Functions", () => {
}
});
describe("sanitizeOutputName", () => {
test("should keep valid characters", () => {
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
expect(sanitize("valid_name-123")).toBe("valid_name-123");
test("should set structured_output with valid data", async () => {
await createMockExecutionFile({
is_flaky: true,
confidence: 0.85,
summary: "Test looks flaky",
});
test("should replace invalid characters with underscores", () => {
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
expect(sanitize("invalid@name!")).toBe("invalid_name_");
expect(sanitize("has spaces")).toBe("has_spaces");
expect(sanitize("has.dots")).toBe("has_dots");
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
expect(setOutputSpy).toHaveBeenCalledWith(
"structured_output",
'{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}',
);
expect(infoSpy).toHaveBeenCalledWith(
"Set structured_output with 3 field(s)",
);
});
test("should handle arrays and nested objects", async () => {
await createMockExecutionFile({
items: ["a", "b", "c"],
config: { key: "value", nested: { deep: true } },
});
test("should handle special characters", () => {
const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_");
expect(sanitize("$field%name&")).toBe("_field_name_");
expect(sanitize("field[0]")).toBe("field_0_");
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
const callArgs = setOutputSpy.mock.calls[0];
expect(callArgs[0]).toBe("structured_output");
const parsed = JSON.parse(callArgs[1]);
expect(parsed).toEqual({
items: ["a", "b", "c"],
config: { key: "value", nested: { deep: true } },
});
});
describe("convertToString", () => {
const 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);
default:
return JSON.stringify(value);
}
};
test("should keep strings as-is", () => {
expect(convertToString("hello")).toBe("hello");
expect(convertToString("")).toBe("");
test("should handle special characters in field names", async () => {
await createMockExecutionFile({
"test-result": "passed",
"item.count": 10,
"user@email": "test",
});
test("should convert booleans to strings", () => {
expect(convertToString(true)).toBe("true");
expect(convertToString(false)).toBe("false");
});
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
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"}]}',
);
});
const callArgs = setOutputSpy.mock.calls[0];
const parsed = JSON.parse(callArgs[1]);
expect(parsed["test-result"]).toBe("passed");
expect(parsed["item.count"]).toBe(10);
expect(parsed["user@email"]).toBe("test");
});
describe("parseAndSetStructuredOutputs integration", () => {
test("should parse and set simple structured outputs", async () => {
await createMockExecutionFile({
is_antonly: true,
confidence: 0.95,
risk: "low",
});
test("should throw error when result exists but structured_output is undefined", async () => {
const messages = [
{ type: "system", subtype: "init" },
{ type: "result", cost_usd: 0.01, duration_ms: 1000 },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
// 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" },
});
});
await expect(
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
).rejects.toThrow(
"json_schema was provided but Claude did not return structured_output",
);
});
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;
test("should throw error when no result message exists", async () => {
const messages = [
{ type: "system", subtype: "init" },
{ type: "turn", content: "test" },
];
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
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_");
});
await expect(
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
).rejects.toThrow(
"json_schema was provided but Claude did not return structured_output",
);
});
describe("error scenarios", () => {
test("should handle malformed JSON", async () => {
await writeFile(TEST_EXECUTION_FILE, "invalid json {");
test("should throw error with 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();
});
await expect(
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
).rejects.toThrow();
});
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;
test("should throw error when file does not exist", async () => {
await expect(
parseAndSetStructuredOutputs("/nonexistent/file.json"),
).rejects.toThrow();
});
expect(displayValue).toBe("a".repeat(97) + "...");
expect(displayValue.length).toBe(100);
});
test("should handle empty structured_output object", async () => {
await createMockExecutionFile({});
test("should not truncate short values", () => {
const shortValue = "short";
const displayValue =
shortValue.length > 100 ? `${shortValue.slice(0, 97)}...` : shortValue;
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
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) + "...");
});
expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
expect(infoSpy).toHaveBeenCalledWith(
"Set structured_output with 0 field(s)",
);
});
});

View File

@@ -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 | "" |
| `plugin_marketplaces` | Newline-separated list of Claude Code plugin marketplace Git URLs to install from (e.g., see example in workflow above). Marketplaces are added before plugin installation | No | "" |
| `plugins` | Newline-separated list of Claude Code plugin names to install (e.g., see example in workflow above). Plugins are installed before Claude Code execution | No | "" |
| `json_schema` | JSON schema for structured output validation. See [Structured Outputs](#structured-outputs) section below | No | "" |
### Deprecated Inputs
@@ -185,6 +186,82 @@ For a comprehensive guide on migrating from v0.x to v1.0, including step-by-step
Focus on the changed files in this PR.
```
## 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
These examples show how to interact with Claude using comments in PRs and issues. By default, Claude will be triggered anytime you mention `@claude`, but you can customize the exact trigger phrase using the `trigger_phrase` input in the workflow.

View File

@@ -68,13 +68,17 @@ jobs:
# 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'
fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
fromJSON(steps.detect.outputs.structured_output).confidence >= 0.7
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "🔄 Flaky test detected (confidence: ${{ steps.detect.outputs.confidence }})"
echo "Summary: ${{ steps.detect.outputs.summary }}"
OUTPUT='${{ steps.detect.outputs.structured_output }}'
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
echo "🔄 Flaky test detected (confidence: $CONFIDENCE)"
echo "Summary: $SUMMARY"
echo ""
echo "Triggering automatic retry..."
@@ -84,10 +88,13 @@ jobs:
# Low confidence flaky detection - skip retry
- name: Low confidence detection
if: |
steps.detect.outputs.is_flaky == 'true' &&
steps.detect.outputs.confidence < '0.7'
fromJSON(steps.detect.outputs.structured_output).is_flaky == true &&
fromJSON(steps.detect.outputs.structured_output).confidence < 0.7
run: |
echo "⚠️ Possible flaky test but confidence too low (${{ steps.detect.outputs.confidence }})"
OUTPUT='${{ steps.detect.outputs.structured_output }}'
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
echo "⚠️ Possible flaky test but confidence too low ($CONFIDENCE)"
echo "Not retrying automatically - manual review recommended"
# Comment on PR if this was a PR build
@@ -96,16 +103,29 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
OUTPUT='${{ steps.detect.outputs.structured_output }}'
IS_FLAKY=$(echo "$OUTPUT" | jq -r '.is_flaky')
CONFIDENCE=$(echo "$OUTPUT" | jq -r '.confidence')
SUMMARY=$(echo "$OUTPUT" | jq -r '.summary')
pr_number=$(gh pr list --head "${{ github.event.workflow_run.head_branch }}" --json number --jq '.[0].number')
if [ -n "$pr_number" ]; then
if [ "$IS_FLAKY" = "true" ]; then
TITLE="🔄 Flaky Test Detected"
ACTION="✅ Automatically retrying the workflow"
else
TITLE="❌ Test Failure"
ACTION="⚠️ This appears to be a real bug - manual intervention needed"
fi
gh pr comment "$pr_number" --body "$(cat <<EOF
## ${{ steps.detect.outputs.is_flaky == 'true' && '🔄 Flaky Test Detected' || '❌ Test Failure' }}
## $TITLE
**Analysis**: ${{ steps.detect.outputs.summary }}
**Confidence**: ${{ steps.detect.outputs.confidence }}
**Analysis**: $SUMMARY
**Confidence**: $CONFIDENCE
${{ steps.detect.outputs.is_flaky == 'true' && '✅ Automatically retrying the workflow' || '⚠️ This appears to be a real bug - manual intervention needed' }}
$ACTION
[View workflow run](${{ github.event.workflow_run.html_url }})
EOF

View File

@@ -7,6 +7,7 @@ import { parseAllowedTools } from "./parse-tools";
import { configureGitAuth } from "../../github/operations/git-config";
import type { GitHubContext } from "../../github/context";
import { isEntityContext } from "../../github/context";
import { appendJsonSchemaArg } from "../../utils/json-schema";
/**
* Extract GitHub context as environment variables for agent mode
@@ -150,17 +151,7 @@ export const agentMode: Mode = {
}
// Add JSON schema if provided
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}'`;
}
claudeArgs = appendJsonSchemaArg(claudeArgs);
// Append user's claude_args (which may have more --mcp-config flags)
claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim();

View File

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

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

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