From dcee434ef2d94ba5af8b51330dd65b9aa9bc04ae Mon Sep 17 00:00:00 2001 From: inigo Date: Tue, 18 Nov 2025 12:08:41 -0800 Subject: [PATCH] fix: workaround GitHub Actions composite action output limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..outputs.structured_output | jq '.field_name' Or in GitHub Actions expressions: fromJSON(steps..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 --- .github/workflows/test-structured-output.yml | 84 ++++++++++++-------- base-action/action.yml | 3 + base-action/src/run-claude.ts | 18 ++++- 3 files changed, 71 insertions(+), 34 deletions(-) diff --git a/.github/workflows/test-structured-output.yml b/.github/workflows/test-structured-output.yml index f5ddf63..c16e4e8 100644 --- a/.github/workflows/test-structured-output.yml +++ b/.github/workflows/test-structured-output.yml @@ -46,27 +46,34 @@ jobs: - 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 @@ -108,28 +115,31 @@ jobs: - 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 @@ -166,27 +176,34 @@ jobs: - 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 @@ -220,15 +237,20 @@ jobs: - 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 diff --git a/base-action/action.yml b/base-action/action.yml index fa1bd2f..fec2cf5 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -92,6 +92,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" diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index e65b5fc..13df52a 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -139,7 +139,11 @@ export function sanitizeOutputName(name: string): string { } // 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 @@ -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); - core.info(`Setting ${entries.length} structured output(s)`); + core.info(`Setting ${entries.length} individual structured output(s)`); for (const [key, value] of entries) { // Validate key before sanitization