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>
This commit is contained in:
inigo
2025-11-18 12:08:41 -08:00
parent e93583852d
commit dcee434ef2
3 changed files with 71 additions and 34 deletions

View File

@@ -46,27 +46,34 @@ jobs:
- 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
if [ "${{ steps.test.outputs.text_field }}" != "hello" ]; then TEXT_FIELD=$(echo "$OUTPUT" | jq -r '.text_field')
echo "❌ String: expected 'hello', got '${{ steps.test.outputs.text_field }}'" if [ "$TEXT_FIELD" != "hello" ]; then
echo "❌ String: expected 'hello', got '$TEXT_FIELD'"
exit 1 exit 1
fi fi
# Test number → string conversion # Test number → string conversion
if [ "${{ steps.test.outputs.number_field }}" != "42" ]; then NUMBER_FIELD=$(echo "$OUTPUT" | jq -r '.number_field')
echo "❌ Number: expected '42', got '${{ steps.test.outputs.number_field }}'" if [ "$NUMBER_FIELD" != "42" ]; then
echo "❌ Number: expected '42', got '$NUMBER_FIELD'"
exit 1 exit 1
fi fi
# Test boolean → "true" conversion # Test boolean → "true" conversion
if [ "${{ steps.test.outputs.boolean_true }}" != "true" ]; then BOOLEAN_TRUE=$(echo "$OUTPUT" | jq -r '.boolean_true')
echo "❌ Boolean true: expected 'true', got '${{ steps.test.outputs.boolean_true }}'" if [ "$BOOLEAN_TRUE" != "true" ]; then
echo "❌ Boolean true: expected 'true', got '$BOOLEAN_TRUE'"
exit 1 exit 1
fi fi
# Test boolean → "false" conversion # Test boolean → "false" conversion
if [ "${{ steps.test.outputs.boolean_false }}" != "false" ]; then BOOLEAN_FALSE=$(echo "$OUTPUT" | jq -r '.boolean_false')
echo "❌ Boolean false: expected 'false', got '${{ steps.test.outputs.boolean_false }}'" if [ "$BOOLEAN_FALSE" != "false" ]; then
echo "❌ Boolean false: expected 'false', got '$BOOLEAN_FALSE'"
exit 1 exit 1
fi fi
@@ -108,28 +115,31 @@ jobs:
- 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
ITEMS='${{ steps.test.outputs.items }}' if ! echo "$OUTPUT" | jq -e '.items | length == 3' > /dev/null; then
if ! echo "$ITEMS" | jq -e '. | length == 3' > /dev/null; then echo "❌ Array not properly formatted"
echo "❌ Array not properly stringified: $ITEMS" echo "$OUTPUT" | jq '.items'
exit 1 exit 1
fi fi
# Objects should be JSON stringified # Objects should be JSON stringified
CONFIG='${{ steps.test.outputs.config }}' if ! echo "$OUTPUT" | jq -e '.config.key == "value"' > /dev/null; then
if ! echo "$CONFIG" | jq -e '.key == "value"' > /dev/null; then echo "❌ Object not properly formatted"
echo "❌ Object not properly stringified: $CONFIG" echo "$OUTPUT" | jq '.config'
exit 1 exit 1
fi fi
# Empty arrays should work # Empty arrays should work
EMPTY='${{ steps.test.outputs.empty_array }}' if ! echo "$OUTPUT" | jq -e '.empty_array | length == 0' > /dev/null; then
if ! echo "$EMPTY" | jq -e '. | length == 0' > /dev/null; then echo "❌ Empty array not properly formatted"
echo "❌ Empty array not properly stringified: $EMPTY" echo "$OUTPUT" | jq '.empty_array'
exit 1 exit 1
fi fi
echo "✅ All complex types JSON stringified correctly" echo "✅ All complex types handled correctly"
test-edge-cases: test-edge-cases:
name: Test Edge Cases name: Test Edge Cases
@@ -166,27 +176,34 @@ jobs:
- 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
if [ "${{ steps.test.outputs.zero }}" != "0" ]; then ZERO=$(echo "$OUTPUT" | jq -r '.zero')
echo "❌ Zero: expected '0', got '${{ steps.test.outputs.zero }}'" if [ "$ZERO" != "0" ]; then
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)
if [ "${{ steps.test.outputs.empty_string }}" != "" ]; then EMPTY_STRING=$(echo "$OUTPUT" | jq -r '.empty_string')
echo "❌ Empty string: expected '', got '${{ steps.test.outputs.empty_string }}'" if [ "$EMPTY_STRING" != "" ]; then
echo "❌ Empty string: expected '', got '$EMPTY_STRING'"
exit 1 exit 1
fi fi
# Negative numbers should work # Negative numbers should work
if [ "${{ steps.test.outputs.negative }}" != "-5" ]; then NEGATIVE=$(echo "$OUTPUT" | jq -r '.negative')
echo "❌ Negative: expected '-5', got '${{ steps.test.outputs.negative }}'" if [ "$NEGATIVE" != "-5" ]; then
echo "❌ Negative: expected '-5', got '$NEGATIVE'"
exit 1 exit 1
fi fi
# Decimals should preserve precision # Decimals should preserve precision
if [ "${{ steps.test.outputs.decimal }}" != "3.14" ]; then DECIMAL=$(echo "$OUTPUT" | jq -r '.decimal')
echo "❌ Decimal: expected '3.14', got '${{ steps.test.outputs.decimal }}'" if [ "$DECIMAL" != "3.14" ]; then
echo "❌ Decimal: expected '3.14', got '$DECIMAL'"
exit 1 exit 1
fi fi
@@ -220,15 +237,20 @@ jobs:
- name: Verify sanitized names work - name: Verify sanitized names work
run: | run: |
# Hyphens should be preserved (GitHub Actions allows them) # Parse the structured_output JSON
if [ "${{ steps.test.outputs.test-result }}" != "passed" ]; then OUTPUT='${{ steps.test.outputs.structured_output }}'
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
if [ "${{ steps.test.outputs.item_count }}" != "10" ]; then ITEM_COUNT=$(echo "$OUTPUT" | jq -r '.item_count')
echo "❌ Underscore name failed" if [ "$ITEM_COUNT" != "10" ]; then
echo "❌ Underscore name failed: expected '10', got '$ITEM_COUNT'"
exit 1 exit 1
fi fi

View File

@@ -92,6 +92,9 @@ outputs:
execution_file: execution_file:
description: "Path to the JSON file containing Claude Code execution log" description: "Path to the JSON file containing Claude Code execution log"
value: ${{ steps.run_claude.outputs.execution_file }} value: ${{ steps.run_claude.outputs.execution_file }}
structured_output:
description: "JSON string containing all structured output fields (use fromJSON() or jq to parse)"
value: ${{ steps.run_claude.outputs.structured_output }}
runs: runs:
using: "composite" using: "composite"

View File

@@ -139,7 +139,11 @@ export function sanitizeOutputName(name: string): string {
} }
// Reserved output names that cannot be used by structured outputs // Reserved output names that cannot be used by structured outputs
const RESERVED_OUTPUTS = ["conclusion", "execution_file"] as const; const RESERVED_OUTPUTS = [
"conclusion",
"execution_file",
"structured_output",
] as const;
/** /**
* Converts values to string format for GitHub Actions outputs * Converts values to string format for GitHub Actions outputs
@@ -191,9 +195,17 @@ async function parseAndSetStructuredOutputs(
); );
} }
// Set GitHub Action output for each field // 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)`,
);
// Also set individual field outputs for direct usage (when not using composite action)
const entries = Object.entries(result.structured_output); const entries = Object.entries(result.structured_output);
core.info(`Setting ${entries.length} structured output(s)`); core.info(`Setting ${entries.length} individual structured output(s)`);
for (const [key, value] of entries) { for (const [key, value] of entries) {
// Validate key before sanitization // Validate key before sanitization