Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions actions/_common/claude-harness/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,10 @@ outputs:
# `result` expecting Claude's text output. claude-code-action@v1.x has no
# such output — `execution_file` is a path to a JSON transcript. We expose
# `result` as an alias so those callers keep working at the type level
# while we migrate them.
# while we migrate them. Migrated callers should instead feed `execution-file`
# to _common/extract-text to recover the agent's text (see #177).
result:
description: "[deprecated] Alias of execution-file. Pre-v1 callers expected text; v1 returns a path."
description: "[deprecated] Alias of execution-file. Pre-v1 callers expected text; v1 returns a path. Use _common/extract-text on execution-file."
value: ${{ steps.invoke.outputs.execution_file }}
comment-id:
description: "[deprecated] No longer populated. claude-code-action@v1.x uses sticky comments managed internally."
Expand Down
41 changes: 41 additions & 0 deletions actions/_common/extract-text/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# ─────────────────────────────────────────────────────────────────────────────
# _common/extract-text — pull the final assistant text out of a claude-harness
# execution file.
# ─────────────────────────────────────────────────────────────────────────────
# claude-code-action@v1.x writes a JSONL transcript to a file; its path is
# exposed by claude-harness as `execution-file` (and the deprecated `result`
# alias). Pre-v1 callers that wanted Claude's *text* output — flag-audit,
# agent-test, eval-smoke, agent-test-gen, create-release — were reading that
# path and treating it as the text itself, producing bodies that were literally
# "/home/runner/_work/_temp/claude-execution-output.json" (see #177).
#
# This helper reads the transcript and returns the agent's final text so those
# callers can keep their "result is text" contract on top of v1.x.
# ─────────────────────────────────────────────────────────────────────────────
name: Extract Agent Text
description: Extract the final assistant text from a claude-harness execution file.

inputs:
execution-file:
description: Path to the claude-harness execution output file.
required: false
default: ""
skipped:
description: '"true" when the agent was skipped (e.g. API key absent). Emits empty text.'
required: false
default: "false"

outputs:
text:
description: The agent's final text output (empty when missing/skipped/unparseable).
value: ${{ steps.run.outputs.text }}

runs:
using: composite
steps:
- id: run
shell: bash
env:
EXECUTION_FILE: ${{ inputs.execution-file }}
SKIPPED: ${{ inputs.skipped }}
run: bash "${{ github.action_path }}/extract-text.sh"
70 changes: 70 additions & 0 deletions actions/_common/extract-text/extract-text.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Extracts the agent's final text output from a claude-harness execution file.
#
# Handles the formats claude-code-action@v1.x (and older builds) can emit:
# 1. JSONL transcript: system/init … assistant … result/success records.
# The final text lives in the result record's `.result` string, or
# failing that the last assistant message's text content blocks.
# 2. Single JSON object (legacy direct-skill output) carrying `.result`
# or assistant-style `.content[].text`.
# 3. Plain text (very old single-string output) — passed through verbatim.
#
# Emits `text=<...>` (heredoc form) on $GITHUB_OUTPUT. On any failure to find
# text — missing file, skipped agent, unparseable transcript — emits empty
# text and exits 0 so callers can decide how to degrade.
set -euo pipefail

emit() {
# $1 = text payload (may be empty / multiline)
{
echo "text<<__OPENCI_TEXT_EOF__"
printf '%s\n' "$1"

Check warning on line 21 in actions/_common/extract-text/extract-text.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Assign this positional parameter to a local variable.

See more on https://sonarcloud.io/project/issues?id=YiAgent_OpenCI&issues=AZ6x7EiglkGDfR-JsbIg&open=AZ6x7EiglkGDfR-JsbIg&pullRequest=178
echo "__OPENCI_TEXT_EOF__"
} >> "$GITHUB_OUTPUT"
}

if [ "${SKIPPED:-false}" = "true" ]; then

Check failure on line 26 in actions/_common/extract-text/extract-text.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=YiAgent_OpenCI&issues=AZ6x7EiglkGDfR-JsbIh&open=AZ6x7EiglkGDfR-JsbIh&pullRequest=178
emit ""
exit 0
fi

file="${EXECUTION_FILE:-}"
if [ -z "$file" ] || [ ! -f "$file" ]; then

Check failure on line 32 in actions/_common/extract-text/extract-text.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=YiAgent_OpenCI&issues=AZ6x7EiglkGDfR-JsbIi&open=AZ6x7EiglkGDfR-JsbIi&pullRequest=178

Check failure on line 32 in actions/_common/extract-text/extract-text.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=YiAgent_OpenCI&issues=AZ6x7EiglkGDfR-JsbIj&open=AZ6x7EiglkGDfR-JsbIj&pullRequest=178
emit ""
exit 0
fi

text=""

# Strategy A — result record's `.result` text (claude-code-action success).
# Slurp so both JSONL streams and single JSON arrays/objects parse as one doc;
# recursive descent finds the result record wherever it sits.
text="$(jq -sr '
[.. | objects | select(.type? == "result") | .result?]
| map(select(type == "string" and length > 0)) | last // empty
' "$file" 2>/dev/null || true)"

# Strategy B — last assistant message's concatenated text content blocks.
if [ -z "$text" ]; then

Check failure on line 48 in actions/_common/extract-text/extract-text.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=YiAgent_OpenCI&issues=AZ6x7EiglkGDfR-JsbIk&open=AZ6x7EiglkGDfR-JsbIk&pullRequest=178
text="$(jq -sr '
[ .. | objects
| select(.type? == "assistant")
| .message?.content? // .content?
| arrays | map(select(.type? == "text") | .text?) | join("")
]
| map(select(type == "string" and length > 0)) | last // empty
' "$file" 2>/dev/null || true)"
fi

# Strategy C — single JSON object that itself carries `.result`.
if [ -z "$text" ]; then

Check failure on line 60 in actions/_common/extract-text/extract-text.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=YiAgent_OpenCI&issues=AZ6x7EiglkGDfR-JsbIl&open=AZ6x7EiglkGDfR-JsbIl&pullRequest=178
text="$(jq -r 'select(type == "object") | .result? // empty | strings' \
"$file" 2>/dev/null | head -n1 || true)"
fi

# Strategy D — file is plain text (not JSON at all): pass it through verbatim.
if [ -z "$text" ] && ! jq -e . "$file" >/dev/null 2>&1; then

Check failure on line 66 in actions/_common/extract-text/extract-text.sh

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use '[[' instead of '[' for conditional tests. The '[[' construct is safer and more feature-rich.

See more on https://sonarcloud.io/project/issues?id=YiAgent_OpenCI&issues=AZ6x7EiglkGDfR-JsbIm&open=AZ6x7EiglkGDfR-JsbIm&pullRequest=178
text="$(cat "$file")"
fi

emit "$text"
10 changes: 9 additions & 1 deletion actions/_common/flag-audit/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,20 @@ runs:
model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }}
github-token: ${{ inputs.github-token }}

# claude-harness `result` is a PATH to the JSONL transcript, not text.
# Extract the agent's final report before filing the issue (see #177).
- name: Extract audit report
id: report
uses: ./.openci/actions/_common/extract-text
with:
execution-file: ${{ steps.harness.outputs.execution-file }}

- name: File audit issue
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
REPO: ${{ github.repository }}
BODY: ${{ steps.harness.outputs.result }}
BODY: ${{ steps.report.outputs.text }}
run: |
set -euo pipefail
if [ -z "$BODY" ]; then
Expand Down
9 changes: 8 additions & 1 deletion actions/ci/eval-smoke/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ inputs:
outputs:
result:
description: AI eval report (JSON-encoded text).
value: ${{ steps.harness.outputs.result }}
value: ${{ steps.report.outputs.text }}

api-base-url:
required: false
Expand Down Expand Up @@ -67,3 +67,10 @@ runs:
api-key: ${{ inputs.anthropic-api-key }}
api-base-url: ${{ inputs.api-base-url }}
model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }}

# `result` is a transcript PATH under claude-code-action@v1.x; extract the
# eval report text so downstream `outputs.result` is the report, not a path.
- id: report
uses: ./.openci/actions/_common/extract-text
with:
execution-file: ${{ steps.harness.outputs.execution-file }}
10 changes: 9 additions & 1 deletion actions/pr/agent-test-gen/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,18 @@ runs:
model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }}
github-token: ${{ inputs.github-token }}

# `result` is a transcript PATH under claude-code-action@v1.x; extract the
# agent's generated output so the staged file holds it, not a path.
- name: Extract generated output
id: gen
uses: ./.openci/actions/_common/extract-text
with:
execution-file: ${{ steps.harness.outputs.execution-file }}

- name: Stage generated files (no auto-commit)
shell: bash
env:
OUTPUT: ${{ steps.harness.outputs.result }}
OUTPUT: ${{ steps.gen.outputs.text }}
run: |
set -euo pipefail
mkdir -p triage/test-gen
Expand Down
11 changes: 10 additions & 1 deletion actions/prd/create-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,22 @@ runs:
model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }}
github-token: ${{ inputs.github-token }}

# `result` is a transcript PATH under claude-code-action@v1.x; extract the
# changelog text so release notes hold the body, not a path.
- name: Extract changelog
id: notes
if: steps.skip.outputs.skip != 'true' && inputs.use-ai-changelog == 'true' && inputs.anthropic-api-key != ''
uses: ./.openci/actions/_common/extract-text
with:
execution-file: ${{ steps.ai.outputs.execution-file }}

- name: Create release
if: steps.skip.outputs.skip != 'true'
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
VER: ${{ steps.ver.outputs.version }}
AI_BODY: ${{ steps.ai.outputs.result }}
AI_BODY: ${{ steps.notes.outputs.text }}
run: |
set -euo pipefail
if [ -n "$AI_BODY" ]; then
Expand Down
10 changes: 9 additions & 1 deletion actions/stg/agent-test/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,19 @@ runs:

api-base-url: ${{ inputs.api-base-url }}
model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }}
# `result` is a transcript PATH under claude-code-action@v1.x; extract the
# agent's finding text so the staged file holds the report, not a path.
- name: Extract findings
id: findings
uses: ./.openci/actions/_common/extract-text
with:
execution-file: ${{ steps.harness.outputs.execution-file }}

- name: Stage findings
shell: bash
env:
L: ${{ inputs.level }}
OUTPUT: ${{ steps.harness.outputs.result }}
OUTPUT: ${{ steps.findings.outputs.text }}
run: |
mkdir -p triage/agent-test
printf '%s\n' "$OUTPUT" > "triage/agent-test/L${L}.json"
Expand Down
121 changes: 121 additions & 0 deletions tests/actions/extract-text.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env bats
# Tests for actions/_common/extract-text/extract-text.sh

bats_require_minimum_version 1.5.0

setup() {
PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)"
SCRIPT="${PROJECT_ROOT}/actions/_common/extract-text/extract-text.sh"
TMPDIR="$(mktemp -d)"
export GITHUB_OUTPUT="${TMPDIR}/output.txt"
touch "$GITHUB_OUTPUT"
}

teardown() {
rm -rf "$TMPDIR"
}

run_extract() {
SKIPPED="${1:-false}" EXECUTION_FILE="${2:-}" run bash "$SCRIPT"
}

# Returns everything between the text<<EOF marker and its terminator.
get_text() {
awk '
/^text<<__OPENCI_TEXT_EOF__$/ {capture=1; next}
/^__OPENCI_TEXT_EOF__$/ {capture=0}
capture {print}
' "$GITHUB_OUTPUT"
}

@test "skipped=true emits empty text" {
run_extract "true"
[ "$status" -eq 0 ]
[ -z "$(get_text)" ]
}

@test "missing execution file emits empty text" {
run_extract "false" "${TMPDIR}/does-not-exist.json"
[ "$status" -eq 0 ]
[ -z "$(get_text)" ]
}

@test "empty execution-file input emits empty text" {
run_extract "false" ""
[ "$status" -eq 0 ]
[ -z "$(get_text)" ]
}

@test "JSONL: result record .result is extracted" {
local ef="${TMPDIR}/exec.jsonl"
cat >"$ef" <<'JSONL'
{"type":"system","subtype":"init","message":"Claude Code initialized"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"working"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"## TL;DR\nAll clear."}
JSONL
run_extract "false" "$ef"
[ "$status" -eq 0 ]
[ "$(get_text)" = "$(printf '## TL;DR\nAll clear.')" ]
}

@test "JSONL: falls back to last assistant text when no result field" {
local ef="${TMPDIR}/exec.jsonl"
cat >"$ef" <<'JSONL'
{"type":"system","subtype":"init","message":"Claude Code initialized"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"first"}]}}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"final report"}]}}
{"type":"result","subtype":"success","is_error":false}
JSONL
run_extract "false" "$ef"
[ "$status" -eq 0 ]
[ "$(get_text)" = "final report" ]
}

@test "JSONL: multiple text blocks in one assistant message are concatenated" {
local ef="${TMPDIR}/exec.jsonl"
cat >"$ef" <<'JSONL'
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"part one "},{"type":"text","text":"part two"}]}}
{"type":"result","subtype":"success","is_error":false}
JSONL
run_extract "false" "$ef"
[ "$status" -eq 0 ]
[ "$(get_text)" = "part one part two" ]
}

@test "single JSON object with .result is extracted" {
local ef="${TMPDIR}/exec.json"
echo '{"result":"single object report"}' > "$ef"
run_extract "false" "$ef"
[ "$status" -eq 0 ]
[ "$(get_text)" = "single object report" ]
}

@test "plain text file is passed through verbatim" {
local ef="${TMPDIR}/exec.txt"
printf 'just some markdown\nsecond line\n' > "$ef"
run_extract "false" "$ef"
[ "$status" -eq 0 ]
[ "$(get_text)" = "$(printf 'just some markdown\nsecond line')" ]
}

@test "result record is preferred over earlier assistant text" {
local ef="${TMPDIR}/exec.jsonl"
cat >"$ef" <<'JSONL'
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"intermediate"}]}}
{"type":"result","subtype":"success","is_error":false,"result":"authoritative"}
JSONL
run_extract "false" "$ef"
[ "$status" -eq 0 ]
[ "$(get_text)" = "authoritative" ]
}

@test "transcript with no text anywhere emits empty text" {
local ef="${TMPDIR}/exec.jsonl"
cat >"$ef" <<'JSONL'
{"type":"system","subtype":"init","message":"Claude Code initialized"}
{"type":"result","subtype":"success","is_error":false,"duration_ms":1234}
JSONL
run_extract "false" "$ef"
[ "$status" -eq 0 ]
[ -z "$(get_text)" ]
}
Loading