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
42 changes: 42 additions & 0 deletions base-action/src/execution-file.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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;
}
2 changes: 2 additions & 0 deletions base-action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 6 additions & 10 deletions base-action/src/run-claude-sdk.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand All @@ -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";

Expand Down Expand Up @@ -172,20 +171,17 @@ export async function runClaudeWithSdk(
}
} catch (error) {
console.error("SDK execution error:", error);
await writeExecutionFile(messages);
throw new Error(`SDK execution error: ${error}`);
}

const result: ClaudeRunResult = {
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
Expand Down
39 changes: 39 additions & 0 deletions base-action/test/execution-file.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
66 changes: 66 additions & 0 deletions base-action/test/run-claude-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
2 changes: 2 additions & 0 deletions src/entrypoints/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Loading