Compare commits

..

3 Commits

Author SHA1 Message Date
Ashwin Bhat
a3c96305c1 fix: move critical outputs to immediately after prepare step
Previously, outputs like GITHUB_TOKEN and branch_name were set after Claude execution completed. This caused issues if Claude failed, as downstream cleanup steps wouldn't have access to these outputs.

Now outputs are set immediately after prepare() completes, ensuring they're available even if Claude execution fails.
2025-08-06 08:24:21 -07:00
Ashwin Bhat
e3a4ac69fe fix: update step reference from claude-code to claude in format-turns
Fixed critical bug where format-turns step was still referencing the old
step name 'claude-code' instead of the new 'claude' step name.
2025-08-06 08:24:20 -07:00
Ashwin Bhat
923d1d0592 feat: merge prepare and run-claude steps into unified entrypoint
- Create run-claude-core.ts as the central Claude execution function
- Update action.yml to use single "Run Claude" step
- Create src/entrypoints/run.ts that combines prepare + run-claude logic
- Pass environment variables as object to Claude instead of setting process.env
- Update create-prompt to return tools (still exports env vars for compatibility)
- Add TODO comments for future refactoring to remove env var dependency
- Both action.yml and base-action continue to work correctly

This simplifies the action flow by removing intermediate steps and environment
variable passing between prepare and run-claude phases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-06 08:24:20 -07:00
9 changed files with 613 additions and 388 deletions

View File

@@ -118,10 +118,10 @@ inputs:
outputs: outputs:
execution_file: execution_file:
description: "Path to the Claude Code execution output file" description: "Path to the Claude Code execution output file"
value: ${{ steps.claude-code.outputs.execution_file }} value: ${{ steps.claude.outputs.execution_file }}
branch_name: branch_name:
description: "The branch created by Claude Code for this execution" description: "The branch created by Claude Code for this execution"
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} value: ${{ steps.claude.outputs.CLAUDE_BRANCH }}
runs: runs:
using: "composite" using: "composite"
@@ -137,20 +137,36 @@ runs:
cd ${GITHUB_ACTION_PATH} cd ${GITHUB_ACTION_PATH}
bun install bun install
- name: Prepare action - name: Run Claude
id: prepare id: claude
shell: bash shell: bash
run: | run: |
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts # Install base-action dependencies
echo "Installing base-action dependencies..."
cd ${GITHUB_ACTION_PATH}/base-action
bun install
echo "Base-action dependencies installed"
cd -
# Install Claude Code globally
bun install -g @anthropic-ai/claude-code@1.0.67
# Setup network restrictions if needed
if [[ "${{ inputs.experimental_allowed_domains }}" != "" ]]; then
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
fi
# Run the unified entrypoint
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts
env: env:
# Mode and trigger configuration
MODE: ${{ inputs.mode }} MODE: ${{ inputs.mode }}
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
LABEL_TRIGGER: ${{ inputs.label_trigger }} LABEL_TRIGGER: ${{ inputs.label_trigger }}
BASE_BRANCH: ${{ inputs.base_branch }} BASE_BRANCH: ${{ inputs.base_branch }}
BRANCH_PREFIX: ${{ inputs.branch_prefix }} BRANCH_PREFIX: ${{ inputs.branch_prefix }}
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }} CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
DIRECT_PROMPT: ${{ inputs.direct_prompt }} DIRECT_PROMPT: ${{ inputs.direct_prompt }}
OVERRIDE_PROMPT: ${{ inputs.override_prompt }} OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
@@ -161,57 +177,21 @@ runs:
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
- name: Install Base Action Dependencies
if: steps.prepare.outputs.contains_trigger == 'true'
shell: bash
run: |
echo "Installing base-action dependencies..."
cd ${GITHUB_ACTION_PATH}/base-action
bun install
echo "Base-action dependencies installed"
cd -
# Install Claude Code globally
bun install -g @anthropic-ai/claude-code@1.0.69
- name: Setup Network Restrictions
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
shell: bash
run: |
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
env:
EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }} EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }}
- name: Run Claude Code # Claude configuration
id: claude-code ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
if: steps.prepare.outputs.contains_trigger == 'true' DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
shell: bash MAX_TURNS: ${{ inputs.max_turns }}
run: | SETTINGS: ${{ inputs.settings }}
TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
# Run the base-action CLAUDE_ENV: ${{ inputs.claude_env }}
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts FALLBACK_MODEL: ${{ inputs.fallback_model }}
env:
# Base-action inputs
CLAUDE_CODE_ACTION: "1"
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
INPUT_ALLOWED_TOOLS: ${{ env.ALLOWED_TOOLS }}
INPUT_DISALLOWED_TOOLS: ${{ env.DISALLOWED_TOOLS }}
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
INPUT_MCP_CONFIG: ${{ steps.prepare.outputs.mcp_config }}
INPUT_SETTINGS: ${{ inputs.settings }}
INPUT_SYSTEM_PROMPT: ""
INPUT_APPEND_SYSTEM_PROMPT: ${{ env.APPEND_SYSTEM_PROMPT }}
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
# Model configuration # Model configuration
MODEL: ${{ inputs.model }}
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }} ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
NODE_VERSION: ${{ env.NODE_VERSION }}
DETAILED_PERMISSION_MESSAGES: "1"
# Provider configuration # Provider configuration
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }} ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
@@ -239,35 +219,35 @@ runs:
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }} VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
- name: Update comment with job link - name: Update comment with job link
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always() if: steps.claude.outputs.contains_trigger == 'true' && steps.claude.outputs.claude_comment_id && always()
shell: bash shell: bash
run: | run: |
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts
env: env:
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }} CLAUDE_COMMENT_ID: ${{ steps.claude.outputs.claude_comment_id }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ steps.claude.outputs.GITHUB_TOKEN }}
GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_EVENT_NAME: ${{ github.event_name }}
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }} TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }} CLAUDE_BRANCH: ${{ steps.claude.outputs.CLAUDE_BRANCH }}
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }} IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }}
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }} BASE_BRANCH: ${{ steps.claude.outputs.BASE_BRANCH }}
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }} CLAUDE_SUCCESS: ${{ steps.claude.outputs.conclusion == 'success' }}
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }} OUTPUT_FILE: ${{ steps.claude.outputs.execution_file || '' }}
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }} TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }} PREPARE_SUCCESS: ${{ steps.claude.outcome == 'success' }}
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} PREPARE_ERROR: ${{ steps.claude.outputs.prepare_error || '' }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
- name: Display Claude Code Report - name: Display Claude Code Report
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' if: steps.claude.outputs.contains_trigger == 'true' && steps.claude.outputs.execution_file != ''
shell: bash shell: bash
run: | run: |
# Try to format the turns, but if it fails, dump the raw JSON # Try to format the turns, but if it fails, dump the raw JSON
if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then
echo "Successfully formatted Claude Code report" echo "Successfully formatted Claude Code report"
else else
echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY
@@ -275,7 +255,7 @@ runs:
echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY echo '```json' >> $GITHUB_STEP_SUMMARY
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY cat "${{ steps.claude.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY
fi fi
@@ -286,6 +266,6 @@ runs:
curl -L \ curl -L \
-X DELETE \ -X DELETE \
-H "Accept: application/vnd.github+json" \ -H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \ -H "Authorization: Bearer ${{ steps.claude.outputs.GITHUB_TOKEN }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \ -H "X-GitHub-Api-Version: 2022-11-28" \
${GITHUB_API_URL:-https://api.github.com}/installation/token ${GITHUB_API_URL:-https://api.github.com}/installation/token

View File

@@ -2,7 +2,7 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { preparePrompt } from "./prepare-prompt"; import { preparePrompt } from "./prepare-prompt";
import { runClaude } from "./run-claude"; import { runClaudeCore } from "./run-claude-core";
import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { validateEnvironmentVariables } from "./validate-env"; import { validateEnvironmentVariables } from "./validate-env";
@@ -21,7 +21,9 @@ async function run() {
promptFile: process.env.INPUT_PROMPT_FILE || "", promptFile: process.env.INPUT_PROMPT_FILE || "",
}); });
await runClaude(promptConfig.path, { await runClaudeCore({
promptFile: promptConfig.path,
settings: process.env.INPUT_SETTINGS,
allowedTools: process.env.INPUT_ALLOWED_TOOLS, allowedTools: process.env.INPUT_ALLOWED_TOOLS,
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
maxTurns: process.env.INPUT_MAX_TURNS, maxTurns: process.env.INPUT_MAX_TURNS,
@@ -31,6 +33,7 @@ async function run() {
claudeEnv: process.env.INPUT_CLAUDE_ENV, claudeEnv: process.env.INPUT_CLAUDE_ENV,
fallbackModel: process.env.INPUT_FALLBACK_MODEL, fallbackModel: process.env.INPUT_FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL, model: process.env.ANTHROPIC_MODEL,
timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES,
}); });
} catch (error) { } catch (error) {
core.setFailed(`Action failed with error: ${error}`); core.setFailed(`Action failed with error: ${error}`);

View File

@@ -0,0 +1,366 @@
#!/usr/bin/env bun
import * as core from "@actions/core";
import { exec } from "child_process";
import { promisify } from "util";
import { unlink, writeFile, stat } from "fs/promises";
import { createWriteStream } from "fs";
import { spawn } from "child_process";
const execAsync = promisify(exec);
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["-p", "--verbose", "--output-format", "stream-json"];
export type ClaudeOptions = {
allowedTools?: string;
disallowedTools?: string;
maxTurns?: string;
mcpConfig?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
claudeEnv?: string;
fallbackModel?: string;
model?: string;
timeoutMinutes?: string;
};
export type RunClaudeConfig = {
promptFile: string;
settings?: string;
allowedTools?: string;
disallowedTools?: string;
maxTurns?: string;
mcpConfig?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
claudeEnv?: string;
fallbackModel?: string;
model?: string;
timeoutMinutes?: string;
env?: Record<string, string>;
};
function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
if (!claudeEnv || claudeEnv.trim() === "") {
return {};
}
const customEnv: Record<string, string> = {};
// Split by lines and parse each line as KEY: VALUE
const lines = claudeEnv.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
continue; // Skip empty lines and comments
}
const colonIndex = trimmedLine.indexOf(":");
if (colonIndex === -1) {
continue; // Skip lines without colons
}
const key = trimmedLine.substring(0, colonIndex).trim();
const value = trimmedLine.substring(colonIndex + 1).trim();
if (key) {
customEnv[key] = value;
}
}
return customEnv;
}
function prepareClaudeArgs(config: RunClaudeConfig): string[] {
const claudeArgs = [...BASE_ARGS];
if (config.allowedTools) {
claudeArgs.push("--allowedTools", config.allowedTools);
}
if (config.disallowedTools) {
claudeArgs.push("--disallowedTools", config.disallowedTools);
}
if (config.maxTurns) {
const maxTurnsNum = parseInt(config.maxTurns, 10);
if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) {
throw new Error(
`maxTurns must be a positive number, got: ${config.maxTurns}`,
);
}
claudeArgs.push("--max-turns", config.maxTurns);
}
if (config.mcpConfig) {
claudeArgs.push("--mcp-config", config.mcpConfig);
}
if (config.systemPrompt) {
claudeArgs.push("--system-prompt", config.systemPrompt);
}
if (config.appendSystemPrompt) {
claudeArgs.push("--append-system-prompt", config.appendSystemPrompt);
}
if (config.fallbackModel) {
claudeArgs.push("--fallback-model", config.fallbackModel);
}
if (config.model) {
claudeArgs.push("--model", config.model);
}
if (config.timeoutMinutes) {
const timeoutMinutesNum = parseInt(config.timeoutMinutes, 10);
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
throw new Error(
`timeoutMinutes must be a positive number, got: ${config.timeoutMinutes}`,
);
}
}
return claudeArgs;
}
export function prepareRunConfig(
promptPath: string,
options: ClaudeOptions,
): { claudeArgs: string[]; promptPath: string; env: Record<string, string> } {
const config: RunClaudeConfig = {
promptFile: promptPath,
...options,
};
const claudeArgs = prepareClaudeArgs(config);
const customEnv = parseCustomEnvVars(config.claudeEnv);
const mergedEnv = {
...customEnv,
...(config.env || {}),
};
return {
claudeArgs,
promptPath,
env: mergedEnv,
};
}
export async function runClaudeCore(config: RunClaudeConfig) {
const claudeArgs = prepareClaudeArgs(config);
// Parse custom environment variables from claudeEnv
const customEnv = parseCustomEnvVars(config.claudeEnv);
// Merge with additional env vars passed in config
const mergedEnv = {
...customEnv,
...(config.env || {}),
};
// Create a named pipe
try {
await unlink(PIPE_PATH);
} catch (e) {
// Ignore if file doesn't exist
}
// Create the named pipe
await execAsync(`mkfifo "${PIPE_PATH}"`);
// Log prompt file size
let promptSize = "unknown";
try {
const stats = await stat(config.promptFile);
promptSize = stats.size.toString();
} catch (e) {
// Ignore error
}
console.log(`Prompt file size: ${promptSize} bytes`);
// Log custom environment variables if any
const totalEnvVars = Object.keys(mergedEnv).length;
if (totalEnvVars > 0) {
const envKeys = Object.keys(mergedEnv).join(", ");
console.log(`Custom environment variables (${totalEnvVars}): ${envKeys}`);
}
// Output to console
console.log(`Running Claude with prompt from file: ${config.promptFile}`);
// Start sending prompt to pipe in background
const catProcess = spawn("cat", [config.promptFile], {
stdio: ["ignore", "pipe", "inherit"],
});
const pipeStream = createWriteStream(PIPE_PATH);
catProcess.stdout.pipe(pipeStream);
catProcess.on("error", (error) => {
console.error("Error reading prompt file:", error);
pipeStream.destroy();
});
const claudeProcess = spawn("claude", claudeArgs, {
stdio: ["pipe", "pipe", "inherit"],
env: {
...process.env,
...mergedEnv,
},
});
// Handle Claude process errors
claudeProcess.on("error", (error) => {
console.error("Error spawning Claude process:", error);
pipeStream.destroy();
});
// Capture output for parsing execution metrics
let output = "";
claudeProcess.stdout.on("data", (data) => {
const text = data.toString();
// Try to parse as JSON and pretty print if it's on a single line
const lines = text.split("\n");
lines.forEach((line: string, index: number) => {
if (line.trim() === "") return;
try {
// Check if this line is a JSON object
const parsed = JSON.parse(line);
const prettyJson = JSON.stringify(parsed, null, 2);
process.stdout.write(prettyJson);
if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n");
}
} catch (e) {
// Not a JSON object, print as is
process.stdout.write(line);
if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n");
}
}
});
output += text;
});
// Handle stdout errors
claudeProcess.stdout.on("error", (error) => {
console.error("Error reading Claude stdout:", error);
});
// Pipe from named pipe to Claude
const pipeProcess = spawn("cat", [PIPE_PATH]);
pipeProcess.stdout.pipe(claudeProcess.stdin);
// Handle pipe process errors
pipeProcess.on("error", (error) => {
console.error("Error reading from named pipe:", error);
claudeProcess.kill("SIGTERM");
});
// Wait for Claude to finish with timeout
let timeoutMs = 10 * 60 * 1000; // Default 10 minutes
if (config.timeoutMinutes) {
timeoutMs = parseInt(config.timeoutMinutes, 10) * 60 * 1000;
} else if (process.env.INPUT_TIMEOUT_MINUTES) {
const envTimeout = parseInt(process.env.INPUT_TIMEOUT_MINUTES, 10);
if (isNaN(envTimeout) || envTimeout <= 0) {
throw new Error(
`INPUT_TIMEOUT_MINUTES must be a positive number, got: ${process.env.INPUT_TIMEOUT_MINUTES}`,
);
}
timeoutMs = envTimeout * 60 * 1000;
}
const exitCode = await new Promise<number>((resolve) => {
let resolved = false;
// Set a timeout for the process
const timeoutId = setTimeout(() => {
if (!resolved) {
console.error(
`Claude process timed out after ${timeoutMs / 1000} seconds`,
);
claudeProcess.kill("SIGTERM");
// Give it 5 seconds to terminate gracefully, then force kill
setTimeout(() => {
try {
claudeProcess.kill("SIGKILL");
} catch (e) {
// Process may already be dead
}
}, 5000);
resolved = true;
resolve(124); // Standard timeout exit code
}
}, timeoutMs);
claudeProcess.on("close", (code) => {
if (!resolved) {
clearTimeout(timeoutId);
resolved = true;
resolve(code || 0);
}
});
claudeProcess.on("error", (error) => {
if (!resolved) {
console.error("Claude process error:", error);
clearTimeout(timeoutId);
resolved = true;
resolve(1);
}
});
});
// Clean up processes
try {
catProcess.kill("SIGTERM");
} catch (e) {
// Process may already be dead
}
try {
pipeProcess.kill("SIGTERM");
} catch (e) {
// Process may already be dead
}
// Clean up pipe file
try {
await unlink(PIPE_PATH);
} catch (e) {
// Ignore errors during cleanup
}
// Set conclusion based on exit code
if (exitCode === 0) {
// Try to process the output and save execution metrics
try {
await writeFile("output.txt", output);
// Process output.txt into JSON and save to execution file
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
await writeFile(EXECUTION_FILE, jsonOutput);
console.log(`Log saved to ${EXECUTION_FILE}`);
} catch (e) {
core.warning(`Failed to process output for execution metrics: ${e}`);
}
core.setOutput("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE);
} else {
core.setOutput("conclusion", "failure");
// Still try to save execution file if we have output
if (output) {
try {
await writeFile("output.txt", output);
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
await writeFile(EXECUTION_FILE, jsonOutput);
core.setOutput("execution_file", EXECUTION_FILE);
} catch (e) {
// Ignore errors when processing output during failure
}
}
process.exit(exitCode);
}
}

View File

@@ -1,331 +1,44 @@
#!/usr/bin/env bun
import * as core from "@actions/core"; import * as core from "@actions/core";
import { exec } from "child_process"; import { preparePrompt } from "./prepare-prompt";
import { promisify } from "util"; import { runClaudeCore } from "./run-claude-core";
import { unlink, writeFile, stat } from "fs/promises"; export { prepareRunConfig, type ClaudeOptions } from "./run-claude-core";
import { createWriteStream } from "fs"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
import { spawn } from "child_process"; import { validateEnvironmentVariables } from "./validate-env";
const execAsync = promisify(exec); async function run() {
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
const BASE_ARGS = ["-p", "--verbose", "--output-format", "stream-json"];
export type ClaudeOptions = {
allowedTools?: string;
disallowedTools?: string;
maxTurns?: string;
mcpConfig?: string;
systemPrompt?: string;
appendSystemPrompt?: string;
claudeEnv?: string;
fallbackModel?: string;
timeoutMinutes?: string;
model?: string;
};
type PreparedConfig = {
claudeArgs: string[];
promptPath: string;
env: Record<string, string>;
};
function parseCustomEnvVars(claudeEnv?: string): Record<string, string> {
if (!claudeEnv || claudeEnv.trim() === "") {
return {};
}
const customEnv: Record<string, string> = {};
// Split by lines and parse each line as KEY: VALUE
const lines = claudeEnv.split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
continue; // Skip empty lines and comments
}
const colonIndex = trimmedLine.indexOf(":");
if (colonIndex === -1) {
continue; // Skip lines without colons
}
const key = trimmedLine.substring(0, colonIndex).trim();
const value = trimmedLine.substring(colonIndex + 1).trim();
if (key) {
customEnv[key] = value;
}
}
return customEnv;
}
export function prepareRunConfig(
promptPath: string,
options: ClaudeOptions,
): PreparedConfig {
const claudeArgs = [...BASE_ARGS];
if (options.allowedTools) {
claudeArgs.push("--allowedTools", options.allowedTools);
}
if (options.disallowedTools) {
claudeArgs.push("--disallowedTools", options.disallowedTools);
}
if (options.maxTurns) {
const maxTurnsNum = parseInt(options.maxTurns, 10);
if (isNaN(maxTurnsNum) || maxTurnsNum <= 0) {
throw new Error(
`maxTurns must be a positive number, got: ${options.maxTurns}`,
);
}
claudeArgs.push("--max-turns", options.maxTurns);
}
if (options.mcpConfig) {
claudeArgs.push("--mcp-config", options.mcpConfig);
}
if (options.systemPrompt) {
claudeArgs.push("--system-prompt", options.systemPrompt);
}
if (options.appendSystemPrompt) {
claudeArgs.push("--append-system-prompt", options.appendSystemPrompt);
}
if (options.fallbackModel) {
claudeArgs.push("--fallback-model", options.fallbackModel);
}
if (options.model) {
claudeArgs.push("--model", options.model);
}
if (options.timeoutMinutes) {
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
throw new Error(
`timeoutMinutes must be a positive number, got: ${options.timeoutMinutes}`,
);
}
}
// Parse custom environment variables
const customEnv = parseCustomEnvVars(options.claudeEnv);
return {
claudeArgs,
promptPath,
env: customEnv,
};
}
export async function runClaude(promptPath: string, options: ClaudeOptions) {
const config = prepareRunConfig(promptPath, options);
// Create a named pipe
try { try {
await unlink(PIPE_PATH); validateEnvironmentVariables();
} catch (e) {
// Ignore if file doesn't exist
}
// Create the named pipe await setupClaudeCodeSettings(process.env.INPUT_SETTINGS);
await execAsync(`mkfifo "${PIPE_PATH}"`);
// Log prompt file size const promptConfig = await preparePrompt({
let promptSize = "unknown"; prompt: process.env.INPUT_PROMPT || "",
try { promptFile: process.env.INPUT_PROMPT_FILE || "",
const stats = await stat(config.promptPath);
promptSize = stats.size.toString();
} catch (e) {
// Ignore error
}
console.log(`Prompt file size: ${promptSize} bytes`);
// Log custom environment variables if any
if (Object.keys(config.env).length > 0) {
const envKeys = Object.keys(config.env).join(", ");
console.log(`Custom environment variables: ${envKeys}`);
}
// Output to console
console.log(`Running Claude with prompt from file: ${config.promptPath}`);
// Start sending prompt to pipe in background
const catProcess = spawn("cat", [config.promptPath], {
stdio: ["ignore", "pipe", "inherit"],
});
const pipeStream = createWriteStream(PIPE_PATH);
catProcess.stdout.pipe(pipeStream);
catProcess.on("error", (error) => {
console.error("Error reading prompt file:", error);
pipeStream.destroy();
}); });
const claudeProcess = spawn("claude", config.claudeArgs, { await runClaudeCore({
stdio: ["pipe", "pipe", "inherit"], promptFile: promptConfig.path,
env: { settings: process.env.INPUT_SETTINGS,
...process.env, allowedTools: process.env.INPUT_ALLOWED_TOOLS,
...config.env, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
}, maxTurns: process.env.INPUT_MAX_TURNS,
mcpConfig: process.env.INPUT_MCP_CONFIG,
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
claudeEnv: process.env.INPUT_CLAUDE_ENV,
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL,
timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES,
}); });
} catch (error) {
// Handle Claude process errors core.setFailed(`Action failed with error: ${error}`);
claudeProcess.on("error", (error) => {
console.error("Error spawning Claude process:", error);
pipeStream.destroy();
});
// Capture output for parsing execution metrics
let output = "";
claudeProcess.stdout.on("data", (data) => {
const text = data.toString();
// Try to parse as JSON and pretty print if it's on a single line
const lines = text.split("\n");
lines.forEach((line: string, index: number) => {
if (line.trim() === "") return;
try {
// Check if this line is a JSON object
const parsed = JSON.parse(line);
const prettyJson = JSON.stringify(parsed, null, 2);
process.stdout.write(prettyJson);
if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n");
}
} catch (e) {
// Not a JSON object, print as is
process.stdout.write(line);
if (index < lines.length - 1 || text.endsWith("\n")) {
process.stdout.write("\n");
}
}
});
output += text;
});
// Handle stdout errors
claudeProcess.stdout.on("error", (error) => {
console.error("Error reading Claude stdout:", error);
});
// Pipe from named pipe to Claude
const pipeProcess = spawn("cat", [PIPE_PATH]);
pipeProcess.stdout.pipe(claudeProcess.stdin);
// Handle pipe process errors
pipeProcess.on("error", (error) => {
console.error("Error reading from named pipe:", error);
claudeProcess.kill("SIGTERM");
});
// Wait for Claude to finish with timeout
let timeoutMs = 10 * 60 * 1000; // Default 10 minutes
if (options.timeoutMinutes) {
timeoutMs = parseInt(options.timeoutMinutes, 10) * 60 * 1000;
} else if (process.env.INPUT_TIMEOUT_MINUTES) {
const envTimeout = parseInt(process.env.INPUT_TIMEOUT_MINUTES, 10);
if (isNaN(envTimeout) || envTimeout <= 0) {
throw new Error(
`INPUT_TIMEOUT_MINUTES must be a positive number, got: ${process.env.INPUT_TIMEOUT_MINUTES}`,
);
}
timeoutMs = envTimeout * 60 * 1000;
}
const exitCode = await new Promise<number>((resolve) => {
let resolved = false;
// Set a timeout for the process
const timeoutId = setTimeout(() => {
if (!resolved) {
console.error(
`Claude process timed out after ${timeoutMs / 1000} seconds`,
);
claudeProcess.kill("SIGTERM");
// Give it 5 seconds to terminate gracefully, then force kill
setTimeout(() => {
try {
claudeProcess.kill("SIGKILL");
} catch (e) {
// Process may already be dead
}
}, 5000);
resolved = true;
resolve(124); // Standard timeout exit code
}
}, timeoutMs);
claudeProcess.on("close", (code) => {
if (!resolved) {
clearTimeout(timeoutId);
resolved = true;
resolve(code || 0);
}
});
claudeProcess.on("error", (error) => {
if (!resolved) {
console.error("Claude process error:", error);
clearTimeout(timeoutId);
resolved = true;
resolve(1);
}
});
});
// Clean up processes
try {
catProcess.kill("SIGTERM");
} catch (e) {
// Process may already be dead
}
try {
pipeProcess.kill("SIGTERM");
} catch (e) {
// Process may already be dead
}
// Clean up pipe file
try {
await unlink(PIPE_PATH);
} catch (e) {
// Ignore errors during cleanup
}
// Set conclusion based on exit code
if (exitCode === 0) {
// Try to process the output and save execution metrics
try {
await writeFile("output.txt", output);
// Process output.txt into JSON and save to execution file
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
await writeFile(EXECUTION_FILE, jsonOutput);
console.log(`Log saved to ${EXECUTION_FILE}`);
} catch (e) {
core.warning(`Failed to process output for execution metrics: ${e}`);
}
core.setOutput("conclusion", "success");
core.setOutput("execution_file", EXECUTION_FILE);
} else {
core.setOutput("conclusion", "failure"); core.setOutput("conclusion", "failure");
process.exit(1);
// Still try to save execution file if we have output
if (output) {
try {
await writeFile("output.txt", output);
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt");
await writeFile(EXECUTION_FILE, jsonOutput);
core.setOutput("execution_file", EXECUTION_FILE);
} catch (e) {
// Ignore errors when processing output during failure
}
}
process.exit(exitCode);
} }
} }
if (import.meta.main) {
run();
}

View File

@@ -811,12 +811,18 @@ f. If you are unable to complete certain steps, such as running a linter or test
return promptContent; return promptContent;
} }
export type CreatePromptResult = {
promptFile: string;
allowedTools: string;
disallowedTools: string;
};
export async function createPrompt( export async function createPrompt(
mode: Mode, mode: Mode,
modeContext: ModeContext, modeContext: ModeContext,
githubData: FetchDataResult, githubData: FetchDataResult,
context: ParsedGitHubContext, context: ParsedGitHubContext,
) { ): Promise<CreatePromptResult> {
try { try {
// Prepare the context for prompt generation // Prepare the context for prompt generation
let claudeCommentId: string = ""; let claudeCommentId: string = "";
@@ -888,8 +894,17 @@ export async function createPrompt(
combinedAllowedTools, combinedAllowedTools,
); );
// TODO: Remove these environment variable exports once modes are updated to use return values
core.exportVariable("ALLOWED_TOOLS", allAllowedTools); core.exportVariable("ALLOWED_TOOLS", allAllowedTools);
core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools); core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools);
const promptFile = `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`;
return {
promptFile,
allowedTools: allAllowedTools,
disallowedTools: allDisallowedTools,
};
} catch (error) { } catch (error) {
core.setFailed(`Create prompt failed with error: ${error}`); core.setFailed(`Create prompt failed with error: ${error}`);
process.exit(1); process.exit(1);

145
src/entrypoints/run.ts Normal file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env bun
/**
* Unified entrypoint that combines prepare and run-claude steps
*/
import * as core from "@actions/core";
import { setupGitHubToken } from "../github/token";
import { checkWritePermissions } from "../github/validation/permissions";
import { createOctokit } from "../github/api/client";
import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry";
import type { ModeName } from "../modes/types";
import { prepare } from "../prepare";
import { runClaudeCore } from "../../base-action/src/run-claude-core";
import { validateEnvironmentVariables } from "../../base-action/src/validate-env";
import { setupClaudeCodeSettings } from "../../base-action/src/setup-claude-code-settings";
async function run() {
try {
// Step 1: Get mode first to determine authentication method
const modeInput = process.env.MODE || DEFAULT_MODE;
// Validate mode input
if (!isValidMode(modeInput)) {
throw new Error(`Invalid mode: ${modeInput}`);
}
const validatedMode: ModeName = modeInput;
// Step 2: Setup GitHub token based on mode
let githubToken: string;
if (validatedMode === "experimental-review") {
// For experimental-review mode, use the default GitHub Action token
githubToken = process.env.DEFAULT_WORKFLOW_TOKEN || "";
if (!githubToken) {
throw new Error(
"DEFAULT_WORKFLOW_TOKEN not found for experimental-review mode",
);
}
console.log("Using default GitHub Action token for review mode");
} else {
// For other modes, use the existing token exchange
githubToken = await setupGitHubToken();
}
const octokit = createOctokit(githubToken);
// Step 3: Parse GitHub context (once for all operations)
const context = parseGitHubContext();
// Step 4: Check write permissions (only for entity contexts)
if (isEntityContext(context)) {
const hasWritePermissions = await checkWritePermissions(
octokit.rest,
context,
);
if (!hasWritePermissions) {
throw new Error(
"Actor does not have write permissions to the repository",
);
}
}
// Step 5: Get mode and check trigger conditions
const mode = getMode(validatedMode, context);
const containsTrigger = mode.shouldTrigger(context);
// Set output for action.yml to check (in case it's still needed)
core.setOutput("contains_trigger", containsTrigger.toString());
if (!containsTrigger) {
console.log("No trigger found, skipping remaining steps");
return;
}
// Step 6: Use the modular prepare function
const prepareResult = await prepare({
context,
octokit,
mode,
githubToken,
});
// Set critical outputs immediately after prepare completes
// This ensures they're available for cleanup even if Claude fails
core.setOutput("GITHUB_TOKEN", githubToken);
core.setOutput("mcp_config", prepareResult.mcpConfig);
if (prepareResult.branchInfo.claudeBranch) {
core.setOutput("branch_name", prepareResult.branchInfo.claudeBranch);
core.setOutput("CLAUDE_BRANCH", prepareResult.branchInfo.claudeBranch);
}
core.setOutput("BASE_BRANCH", prepareResult.branchInfo.baseBranch);
if (prepareResult.commentId) {
core.setOutput("claude_comment_id", prepareResult.commentId.toString());
}
// Step 7: The mode.prepare() call already created the prompt and set up tools
// We need to get the allowed/disallowed tools from environment variables
// TODO: Update Mode interface to return tools from prepare() instead of relying on env vars
const allowedTools = process.env.ALLOWED_TOOLS || "";
const disallowedTools = process.env.DISALLOWED_TOOLS || "";
const promptFile = `${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`;
// Step 8: Validate environment and setup Claude settings
validateEnvironmentVariables();
await setupClaudeCodeSettings(process.env.SETTINGS);
// Step 9: Run Claude Code
console.log("Running Claude Code...");
// Build environment object to pass to Claude
const claudeEnvObject: Record<string, string> = {
GITHUB_TOKEN: githubToken,
NODE_VERSION: process.env.NODE_VERSION || "18.x",
DETAILED_PERMISSION_MESSAGES: "1",
CLAUDE_CODE_ACTION: "1",
};
await runClaudeCore({
promptFile,
settings: process.env.SETTINGS,
allowedTools,
disallowedTools,
maxTurns: process.env.MAX_TURNS,
mcpConfig: prepareResult.mcpConfig,
systemPrompt: "",
appendSystemPrompt: "",
claudeEnv: process.env.CLAUDE_ENV,
fallbackModel: process.env.FALLBACK_MODEL,
model: process.env.ANTHROPIC_MODEL || process.env.MODEL,
timeoutMinutes: process.env.TIMEOUT_MINUTES || "30",
env: claudeEnvObject,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
core.setFailed(`Action failed with error: ${errorMessage}`);
// Also output the clean error message for the action to capture
core.setOutput("prepare_error", errorMessage);
core.setOutput("conclusion", "failure");
process.exit(1);
}
}
if (import.meta.main) {
run();
}

View File

@@ -242,6 +242,7 @@ This ensures users get value from the review even before checking individual inl
claudeBranch: branchInfo.claudeBranch, claudeBranch: branchInfo.claudeBranch,
}); });
// TODO: Capture and return the allowed/disallowed tools from createPrompt
await createPrompt(reviewMode, modeContext, githubData, context); await createPrompt(reviewMode, modeContext, githubData, context);
// Export tool environment variables for review mode // Export tool environment variables for review mode

View File

@@ -98,6 +98,7 @@ export const tagMode: Mode = {
claudeBranch: branchInfo.claudeBranch, claudeBranch: branchInfo.claudeBranch,
}); });
// TODO: Capture and return the allowed/disallowed tools from createPrompt
await createPrompt(tagMode, modeContext, githubData, context); await createPrompt(tagMode, modeContext, githubData, context);
// Get MCP configuration // Get MCP configuration

View File

@@ -10,6 +10,7 @@ export type PrepareResult = {
currentBranch: string; currentBranch: string;
}; };
mcpConfig: string; mcpConfig: string;
// TODO: Add allowedTools and disallowedTools here once modes are updated
}; };
export type PrepareOptions = { export type PrepareOptions = {