mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
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>
This commit is contained in:
108
action.yml
108
action.yml
@@ -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,31 +219,31 @@ 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
366
base-action/src/run-claude-core.ts
Normal file
366
base-action/src/run-claude-core.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, {
|
|
||||||
stdio: ["pipe", "pipe", "inherit"],
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...config.env,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
await runClaudeCore({
|
||||||
});
|
promptFile: promptConfig.path,
|
||||||
|
settings: process.env.INPUT_SETTINGS,
|
||||||
// Handle stdout errors
|
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||||
claudeProcess.stdout.on("error", (error) => {
|
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||||
console.error("Error reading Claude stdout:", error);
|
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||||
});
|
mcpConfig: process.env.INPUT_MCP_CONFIG,
|
||||||
|
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
|
||||||
// Pipe from named pipe to Claude
|
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
||||||
const pipeProcess = spawn("cat", [PIPE_PATH]);
|
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||||
pipeProcess.stdout.pipe(claudeProcess.stdin);
|
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||||
|
model: process.env.ANTHROPIC_MODEL,
|
||||||
// Handle pipe process errors
|
timeoutMinutes: process.env.INPUT_TIMEOUT_MINUTES,
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
claudeProcess.on("error", (error) => {
|
core.setFailed(`Action failed with 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
151
src/entrypoints/run.ts
Normal file
151
src/entrypoints/run.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
#!/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,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run Claude using the shared core function
|
||||||
|
try {
|
||||||
|
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 (claudeError) {
|
||||||
|
// The outputs should already be set by runClaudeCore
|
||||||
|
// Just re-throw to handle in outer catch
|
||||||
|
throw claudeError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set outputs that were previously set by prepare step
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
} 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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user