From f391cec4b508e3cce7f9fccdcf654303cbc3113c Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 4 Jul 2026 18:07:17 +0530 Subject: [PATCH 1/3] Fix action summary for structured output --- action.yml | 8 +-- scripts/action-summary.mjs | 79 +++++++++++++++++++++++++ scripts/action-summary.test.mjs | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 scripts/action-summary.mjs create mode 100644 scripts/action-summary.test.mjs diff --git a/action.yml b/action.yml index a32ba38e..5e55151d 100644 --- a/action.yml +++ b/action.yml @@ -180,6 +180,7 @@ runs: ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }} ZERO_INPUT_WORKTREE: ${{ inputs.worktree }} ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }} + ACTION_PATH: ${{ github.action_path }} RUNNER_TEMP: ${{ runner.temp }} run: | set -euo pipefail @@ -247,10 +248,9 @@ runs: echo "exit-code=${code}" >> "${GITHUB_OUTPUT}" echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}" - # Best-effort one-line summary: last non-empty line of the captured output. - summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)" - # Keep the summary single-line and bounded for the step output. - summary="$(printf '%s' "${summary}" | cut -c1-280)" + # Best-effort one-line summary. Structured output ends with lifecycle + # metadata, so parse final/error events instead of tailing stdout. + summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}")" { echo "summary< JSON.stringify(event)).join('\n'); +} + +test('stream-json final followed by run_end returns final text', () => { + const output = jsonl([ + { type: 'final', text: 'Done. Tests passed.' }, + { type: 'run_end', status: 'success', exitCode: 0 }, + ]); + + assert.equal(summarizeOutput('stream-json', output), 'Done. Tests passed.'); +}); + +test('stream-json error followed by run_end returns error message', () => { + const output = jsonl([ + { type: 'error', code: 'provider_error', message: 'provider request failed' }, + { type: 'run_end', status: 'error', exitCode: 3 }, + ]); + + assert.equal(summarizeOutput('stream-json', output), 'provider request failed'); +}); + +test('json final followed by done returns final text', () => { + const output = jsonl([ + { type: 'final', text: 'Review complete.' }, + { type: 'done', exit_code: 0 }, + ]); + + assert.equal(summarizeOutput('json', output), 'Review complete.'); +}); + +test('json error followed by done returns error message', () => { + const output = jsonl([ + { type: 'error', code: 'exec_failed', message: 'command failed' }, + { type: 'done', exit_code: 1 }, + ]); + + assert.equal(summarizeOutput('json', output), 'command failed'); +}); + +test('text output returns last non-empty line', () => { + const output = '\nfirst line\n\n final streamed line \n'; + + assert.equal(summarizeOutput('text', output), ' final streamed line '); +}); + +test('unknown format falls back to text behavior', () => { + const output = 'alpha\n beta \n'; + + assert.equal(summarizeOutput('xml', output), ' beta '); +}); + +test('malformed structured output falls back safely', () => { + const output = '{not-json}\nraw fallback\n'; + + assert.equal(summarizeOutput('stream-json', output), 'raw fallback'); +}); + +test('long multiline final becomes one-line 280-char summary', () => { + const text = `first line\n${'x'.repeat(400)}`; + const summary = summarizeOutput('stream-json', jsonl([{ type: 'final', text }])); + + assert.equal(summary.length, 280); + assert.equal(summary.includes('\n'), false); + assert.equal(summary, `first line ${'x'.repeat(400)}`.slice(0, 280)); +}); + +test('cli reads output file and prints summary', () => { + const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-')); + try { + const outputFile = join(dir, 'zero-output.jsonl'); + writeFileSync( + outputFile, + jsonl([ + { type: 'final', text: 'CLI summary' }, + { type: 'run_end', status: 'success', exitCode: 0 }, + ]), + ); + + const result = spawnSync(process.execPath, [scriptPath, 'stream-json', outputFile], { + encoding: 'utf8', + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, 'CLI summary\n'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 25030f4babcd7b0b8df9b937b208e25d7e95c77c Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 4 Jul 2026 18:16:28 +0530 Subject: [PATCH 2/3] Address action summary review comments --- scripts/action-summary.mjs | 26 +++++++++++-------- scripts/action-summary.test.mjs | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/scripts/action-summary.mjs b/scripts/action-summary.mjs index ba1d1352..6392aa53 100644 --- a/scripts/action-summary.mjs +++ b/scripts/action-summary.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; const SUMMARY_LIMIT = 280; const STRUCTURED_FORMATS = new Set(['json', 'stream-json']); @@ -28,8 +29,7 @@ function lastNonEmptyLine(contents) { } function summarizeStructured(contents) { - let finalText; - let errorMessage; + let summary; for (const line of String(contents ?? '').split(/\r?\n/)) { if (!line.trim()) { @@ -44,17 +44,14 @@ function summarizeStructured(contents) { } if (event?.type === 'final' && typeof event.text === 'string') { - finalText = event.text; + summary = event.text; } else if (event?.type === 'error' && typeof event.message === 'string') { - errorMessage = event.message; + summary = event.message; } } - if (finalText !== undefined) { - return normalizeSummary(finalText); - } - if (errorMessage !== undefined) { - return normalizeSummary(errorMessage); + if (summary !== undefined) { + return normalizeSummary(summary); } return normalizeSummary(lastNonEmptyLine(contents)); } @@ -67,7 +64,14 @@ export function summarizeOutput(format, contents) { return summarizeStructured(contents); } -if (process.argv[1] === fileURLToPath(import.meta.url)) { +function isMainModule() { + return ( + process.argv[1] !== undefined && + pathToFileURL(resolve(process.argv[1])).href === import.meta.url + ); +} + +if (isMainModule()) { const [format, outputFile] = process.argv.slice(2); let contents = ''; try { diff --git a/scripts/action-summary.test.mjs b/scripts/action-summary.test.mjs index 0ab077db..3b14c4ed 100644 --- a/scripts/action-summary.test.mjs +++ b/scripts/action-summary.test.mjs @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { summarizeOutput } from './action-summary.mjs'; const scriptPath = fileURLToPath(new URL('./action-summary.mjs', import.meta.url)); +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); function jsonl(events) { return events.map((event) => JSON.stringify(event)).join('\n'); @@ -32,6 +33,16 @@ test('stream-json error followed by run_end returns error message', () => { assert.equal(summarizeOutput('stream-json', output), 'provider request failed'); }); +test('stream-json final then error returns error message', () => { + const output = jsonl([ + { type: 'final', text: 'partial answer' }, + { type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' }, + { type: 'run_end', status: 'incomplete', exitCode: 4 }, + ]); + + assert.equal(summarizeOutput('stream-json', output), 'run stopped with work unfinished'); +}); + test('json final followed by done returns final text', () => { const output = jsonl([ { type: 'final', text: 'Review complete.' }, @@ -50,6 +61,16 @@ test('json error followed by done returns error message', () => { assert.equal(summarizeOutput('json', output), 'command failed'); }); +test('json final then error returns error message', () => { + const output = jsonl([ + { type: 'final', text: 'partial answer' }, + { type: 'error', code: 'incomplete', message: 'run stopped with work unfinished' }, + { type: 'done', exit_code: 4 }, + ]); + + assert.equal(summarizeOutput('json', output), 'run stopped with work unfinished'); +}); + test('text output returns last non-empty line', () => { const output = '\nfirst line\n\n final streamed line \n'; @@ -100,3 +121,28 @@ test('cli reads output file and prints summary', () => { rmSync(dir, { recursive: true, force: true }); } }); + +test('cli runs when invoked with relative script path', () => { + const dir = mkdtempSync(join(tmpdir(), 'zero-action-summary-')); + try { + const outputFile = join(dir, 'zero-output.jsonl'); + writeFileSync( + outputFile, + jsonl([ + { type: 'final', text: 'relative CLI summary' }, + { type: 'run_end', status: 'success', exitCode: 0 }, + ]), + ); + + const result = spawnSync(process.execPath, ['scripts/action-summary.mjs', 'stream-json', outputFile], { + cwd: repoRoot, + encoding: 'utf8', + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, 'relative CLI summary\n'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From 57dcd942f61594c09fec5976ecbc48e069aec9a0 Mon Sep 17 00:00:00 2001 From: Gautam Manchandani Date: Sat, 4 Jul 2026 20:24:20 +0530 Subject: [PATCH 3/3] Keep action summary extraction best effort --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 5e55151d..14bbfcc6 100644 --- a/action.yml +++ b/action.yml @@ -250,7 +250,7 @@ runs: # Best-effort one-line summary. Structured output ends with lifecycle # metadata, so parse final/error events instead of tailing stdout. - summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}")" + summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)" { echo "summary<