diff --git a/base-action/src/execution-file.ts b/base-action/src/execution-file.ts new file mode 100644 index 000000000..3cf396191 --- /dev/null +++ b/base-action/src/execution-file.ts @@ -0,0 +1,42 @@ +import * as core from "@actions/core"; +import { existsSync } from "fs"; +import { writeFile } from "fs/promises"; +import { join } from "path"; + +const EXECUTION_FILENAME = "claude-execution-output.json"; + +export function getExecutionFilePath(): string | undefined { + if (!process.env.RUNNER_TEMP) { + return undefined; + } + return join(process.env.RUNNER_TEMP, EXECUTION_FILENAME); +} + +export async function writeExecutionFile( + messages: unknown[], +): Promise { + const executionFile = getExecutionFilePath(); + if (!executionFile) { + core.warning("Failed to write execution file: RUNNER_TEMP is not set"); + return undefined; + } + + try { + await writeFile(executionFile, JSON.stringify(messages, null, 2)); + console.log(`Log saved to ${executionFile}`); + return executionFile; + } catch (error) { + core.warning(`Failed to write execution file: ${error}`); + return undefined; + } +} + +export function setExecutionFileOutputIfPresent(): string | undefined { + const executionFile = getExecutionFilePath(); + if (!executionFile || !existsSync(executionFile)) { + return undefined; + } + + core.setOutput("execution_file", executionFile); + return executionFile; +} diff --git a/base-action/src/index.ts b/base-action/src/index.ts index 160e64154..8ec84ac1b 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -6,6 +6,7 @@ import { runClaude } from "./run-claude"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { validateEnvironmentVariables } from "./validate-env"; import { installPlugins } from "./install-plugins"; +import { setExecutionFileOutputIfPresent } from "./execution-file"; async function run() { try { @@ -62,6 +63,7 @@ async function run() { core.setOutput("structured_output", result.structuredOutput); } } catch (error) { + setExecutionFileOutputIfPresent(); core.setFailed(`Action failed with error: ${error}`); core.setOutput("conclusion", "failure"); process.exit(1); diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index e37184a7f..e65d93c26 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -1,5 +1,5 @@ import * as core from "@actions/core"; -import { readFile, writeFile, access } from "fs/promises"; +import { readFile, access } from "fs/promises"; import { dirname, join } from "path"; import { query } from "@anthropic-ai/claude-agent-sdk"; import type { @@ -8,6 +8,7 @@ import type { SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import type { ParsedSdkOptions } from "./parse-sdk-options"; +import { writeExecutionFile } from "./execution-file"; export type ClaudeRunResult = { executionFile?: string; @@ -16,8 +17,6 @@ export type ClaudeRunResult = { structuredOutput?: string; }; -const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`; - /** Filename for the user request file, written by prompt generation */ const USER_REQUEST_FILENAME = "claude-user-request.txt"; @@ -172,6 +171,7 @@ export async function runClaudeWithSdk( } } catch (error) { console.error("SDK execution error:", error); + await writeExecutionFile(messages); throw new Error(`SDK execution error: ${error}`); } @@ -179,13 +179,9 @@ export async function runClaudeWithSdk( conclusion: "failure", }; - // Write execution file - try { - await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2)); - console.log(`Log saved to ${EXECUTION_FILE}`); - result.executionFile = EXECUTION_FILE; - } catch (error) { - core.warning(`Failed to write execution file: ${error}`); + const executionFile = await writeExecutionFile(messages); + if (executionFile) { + result.executionFile = executionFile; } // Extract session_id from system.init message diff --git a/base-action/test/execution-file.test.ts b/base-action/test/execution-file.test.ts new file mode 100644 index 000000000..11b00f52d --- /dev/null +++ b/base-action/test/execution-file.test.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env bun + +import * as core from "@actions/core"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { setExecutionFileOutputIfPresent } from "../src/execution-file"; + +describe("execution file output", () => { + const originalRunnerTemp = process.env.RUNNER_TEMP; + let tempDir: string | undefined; + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } + process.env.RUNNER_TEMP = originalRunnerTemp; + }); + + test("sets execution_file output when the default execution file exists", async () => { + const setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {}); + tempDir = await mkdtemp(join(tmpdir(), "claude-execution-file-")); + process.env.RUNNER_TEMP = tempDir; + const executionFile = join(tempDir, "claude-execution-output.json"); + await writeFile(executionFile, "[]"); + + try { + expect(setExecutionFileOutputIfPresent()).toBe(executionFile); + expect(setOutputSpy).toHaveBeenCalledWith( + "execution_file", + executionFile, + ); + } finally { + setOutputSpy.mockRestore(); + } + }); +}); diff --git a/base-action/test/run-claude-sdk.test.ts b/base-action/test/run-claude-sdk.test.ts new file mode 100644 index 000000000..877e88463 --- /dev/null +++ b/base-action/test/run-claude-sdk.test.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env bun + +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; + +describe("runClaudeWithSdk", () => { + const originalRunnerTemp = process.env.RUNNER_TEMP; + let tempDir: string | undefined; + + afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } + process.env.RUNNER_TEMP = originalRunnerTemp; + }); + + test("writes the execution file when the SDK throws after yielding messages", async () => { + const consoleErrorSpy = spyOn(console, "error").mockImplementation( + () => {}, + ); + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + + tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-")); + process.env.RUNNER_TEMP = tempDir; + + const promptPath = join(tempDir, "prompt.txt"); + await writeFile(promptPath, "test prompt"); + + const initMessage = { + type: "system", + subtype: "init", + session_id: "session-123", + model: "claude-sonnet-4-6", + }; + + mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: async function* () { + yield initMessage; + throw new Error("Claude Code returned error_max_turns"); + }, + })); + + try { + const { runClaudeWithSdk } = await import("../src/run-claude-sdk"); + + await expect( + runClaudeWithSdk(promptPath, { + sdkOptions: {}, + showFullOutput: false, + hasJsonSchema: false, + }), + ).rejects.toThrow("SDK execution error"); + + const executionFile = join(tempDir, "claude-execution-output.json"); + await expect(readFile(executionFile, "utf-8")).resolves.toBe( + JSON.stringify([initMessage], null, 2), + ); + } finally { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + } + }); +}); diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index c18ae2022..ffeb17c4c 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -40,6 +40,7 @@ import { installPlugins } from "../../base-action/src/install-plugins"; import { preparePrompt } from "../../base-action/src/prepare-prompt"; import { runClaude } from "../../base-action/src/run-claude"; import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk"; +import { setExecutionFileOutputIfPresent } from "../../base-action/src/execution-file"; /** * Install Claude Code CLI, handling retry logic and custom executable paths. @@ -296,6 +297,7 @@ async function run() { core.setOutput("conclusion", claudeResult.conclusion); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + executionFile ??= setExecutionFileOutputIfPresent(); // Only mark as prepare failure if we haven't completed the prepare phase if (!prepareCompleted) { prepareSuccess = false;