mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
ci: Enhance Claude task runner workflow (#26267)
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds the Claude task prompt and writes it to GITHUB_ENV.
|
||||
* Uses a random delimiter to prevent heredoc collision with user input.
|
||||
*
|
||||
* Usage: node prepare-claude-prompt.mjs
|
||||
*
|
||||
* Environment variables:
|
||||
* INPUT_TASK - The task description (required)
|
||||
* USE_RAW_PROMPT - "true" to pass task directly without wrapping
|
||||
* GITHUB_ENV - Path to GitHub env file (set by Actions)
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { appendFileSync, readdirSync } from 'node:fs';
|
||||
|
||||
const task = process.env.INPUT_TASK;
|
||||
const useRaw = process.env.USE_RAW_PROMPT === 'true';
|
||||
const envFile = process.env.GITHUB_ENV;
|
||||
|
||||
if (!task) {
|
||||
console.error('INPUT_TASK environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!envFile) {
|
||||
console.error('GITHUB_ENV environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let prompt;
|
||||
|
||||
if (useRaw) {
|
||||
prompt = task;
|
||||
} else {
|
||||
// List available templates so Claude knows what exists (reads them if needed)
|
||||
const templateDir = '.github/claude-templates';
|
||||
let templateSection = '';
|
||||
try {
|
||||
const files = readdirSync(templateDir).filter((f) => f.endsWith('.md'));
|
||||
if (files.length > 0) {
|
||||
const listing = files.map((f) => ` - ${templateDir}/${f}`).join('\n');
|
||||
templateSection = `\n# Templates\nThese guides are available if relevant to your task. Read any that match before starting:\n${listing}`;
|
||||
}
|
||||
} catch {
|
||||
// No templates directory, skip
|
||||
}
|
||||
|
||||
prompt = `# Task
|
||||
${task}
|
||||
${templateSection}
|
||||
# Instructions
|
||||
1. Read any relevant templates listed above before starting
|
||||
2. Complete the task described above
|
||||
3. Make commits as you work - the last commit message will be used as the PR title
|
||||
4. IMPORTANT: End every commit message with: Co-authored-by: Claude <noreply@anthropic.com>
|
||||
5. Ensure code passes linting and type checks before finishing
|
||||
|
||||
# Token Optimization
|
||||
When running lint/typecheck, suppress verbose output:
|
||||
pnpm lint 2>&1 | tail -30
|
||||
pnpm typecheck 2>&1 | tail -30`;
|
||||
}
|
||||
|
||||
// Random delimiter guarantees no collision with user content
|
||||
const delimiter = `CLAUDE_PROMPT_DELIM_${randomUUID().replace(/-/g, '')}`;
|
||||
appendFileSync(envFile, `CLAUDE_PROMPT<<${delimiter}\n${prompt}\n${delimiter}\n`);
|
||||
|
||||
console.log(`Prompt written to GITHUB_ENV (${prompt.length} chars)`);
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sends a callback to the resume URL with the Claude task result.
|
||||
* Uses fetch() directly to avoid E2BIG errors from shell argument limits.
|
||||
*
|
||||
* Usage: node resume-callback.mjs
|
||||
*
|
||||
* Environment variables:
|
||||
* RESUME_URL - Callback URL to POST to (required)
|
||||
* EXECUTION_FILE - Path to Claude's execution output JSON (optional)
|
||||
* CLAUDE_OUTCOME - "success" or "failure" (required)
|
||||
* CLAUDE_SESSION_ID - Session ID for resuming conversations (optional)
|
||||
* BRANCH_NAME - Git branch name (optional)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
const resumeUrl = process.env.RESUME_URL;
|
||||
const executionFile = process.env.EXECUTION_FILE;
|
||||
const claudeOutcome = process.env.CLAUDE_OUTCOME;
|
||||
const sessionId = process.env.CLAUDE_SESSION_ID ?? '';
|
||||
const branchName = process.env.BRANCH_NAME ?? '';
|
||||
|
||||
if (!resumeUrl) {
|
||||
console.error('RESUME_URL environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const success = claudeOutcome === 'success';
|
||||
let result = null;
|
||||
|
||||
if (executionFile && existsSync(executionFile)) {
|
||||
try {
|
||||
const execution = JSON.parse(readFileSync(executionFile, 'utf-8'));
|
||||
// Extract the last element (Claude's final result message)
|
||||
result = Array.isArray(execution) ? execution.at(-1) : execution;
|
||||
} catch (err) {
|
||||
console.warn(`Failed to parse execution file: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ success, branch: branchName, sessionId, result });
|
||||
|
||||
try {
|
||||
const response = await fetch(resumeUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
});
|
||||
|
||||
console.log(`Callback sent: ${response.status} ${response.statusText}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
console.error(`Callback failed: ${body}`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Callback error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -7,19 +7,10 @@ on:
|
||||
description: 'Task description - what should Claude do?'
|
||||
required: true
|
||||
type: string
|
||||
user_token:
|
||||
description: 'Your GitHub PAT (required for PR authorship - you cannot approve PRs you author)'
|
||||
required: false
|
||||
type: string
|
||||
resumeUrl:
|
||||
description: 'Optional callback URL to call with the result when the workflow completes'
|
||||
required: false
|
||||
type: string
|
||||
createPr:
|
||||
description: 'Create a pull request with the changes'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
model:
|
||||
description: 'Claude model to use (e.g., claude-opus-4-6, claude-sonnet-4-5, claude-haiku-4-5)'
|
||||
required: false
|
||||
@@ -30,6 +21,11 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
max_turns:
|
||||
description: 'Maximum conversation turns before Claude stops gracefully'
|
||||
required: false
|
||||
type: number
|
||||
default: 50
|
||||
ref:
|
||||
description: 'Git ref (branch/tag/SHA) to check out and work on'
|
||||
required: false
|
||||
@@ -46,14 +42,22 @@ jobs:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Mask user token
|
||||
run: echo "::add-mask::${{ inputs.user_token }}"
|
||||
- name: Generate GitHub App Token
|
||||
id: generate_token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
||||
with:
|
||||
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
|
||||
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 1
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
- name: Configure git remote with token
|
||||
run: git remote set-url origin "https://x-access-token:${{ steps.generate_token.outputs.token }}@github.com/${{ github.repository }}.git"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-nodejs
|
||||
@@ -67,62 +71,16 @@ jobs:
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
|
||||
- name: Configure git author
|
||||
env:
|
||||
GH_TOKEN: ${{ inputs.user_token || github.token }}
|
||||
run: |
|
||||
if [ -n "${{ inputs.user_token }}" ]; then
|
||||
# Resolve identity from the PAT owner
|
||||
USER_DATA=$(gh api user)
|
||||
USER_NAME=$(echo "$USER_DATA" | jq -r '.name // .login')
|
||||
USER_LOGIN=$(echo "$USER_DATA" | jq -r '.login')
|
||||
USER_ID=$(echo "$USER_DATA" | jq -r '.id')
|
||||
git config user.name "$USER_NAME"
|
||||
git config user.email "${USER_ID}+${USER_LOGIN}@users.noreply.github.com"
|
||||
echo "Git author configured as: $USER_NAME <${USER_ID}+${USER_LOGIN}@users.noreply.github.com>"
|
||||
else
|
||||
# Fall back to the user who triggered the workflow
|
||||
git config user.name "${{ github.actor }}"
|
||||
git config user.email "${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com"
|
||||
echo "Git author configured as: ${{ github.actor }}"
|
||||
fi
|
||||
git config user.name "${{ github.actor }}"
|
||||
git config user.email "${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com"
|
||||
echo "Git author configured as: ${{ github.actor }}"
|
||||
|
||||
- name: Prepare Claude prompt
|
||||
env:
|
||||
INPUT_TASK: ${{ inputs.task }}
|
||||
run: |
|
||||
if [ "${{ inputs.use_raw_prompt }}" = "true" ]; then
|
||||
# Pass the task input directly without wrapping
|
||||
{
|
||||
echo 'CLAUDE_PROMPT<<EOF'
|
||||
echo "$INPUT_TASK"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_ENV"
|
||||
else
|
||||
# Build the prompt with task and pointer to templates
|
||||
{
|
||||
echo 'CLAUDE_PROMPT<<EOF'
|
||||
echo "# Task"
|
||||
echo "$INPUT_TASK"
|
||||
echo ""
|
||||
echo "# Guidelines"
|
||||
echo "Check .github/claude-templates/ for relevant guides before starting."
|
||||
echo "Read any templates that match your task type (e.g., security-fix.md for CVE fixes)."
|
||||
echo ""
|
||||
echo "# Instructions"
|
||||
echo "1. Read relevant templates from .github/claude-templates/ first"
|
||||
echo "2. Complete the task described above"
|
||||
echo "3. Follow the guidelines from the templates"
|
||||
echo "4. Make commits as you work - the last commit message will be used as the PR title"
|
||||
echo "5. IMPORTANT: End every commit message with: Co-authored-by: Claude <noreply@anthropic.com>"
|
||||
echo "6. Ensure code passes linting and type checks before finishing"
|
||||
echo ""
|
||||
echo "# Token Optimization"
|
||||
echo "When running lint/typecheck, suppress verbose output:"
|
||||
echo " pnpm lint 2>&1 | tail -30"
|
||||
echo " pnpm typecheck 2>&1 | tail -30"
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_ENV"
|
||||
fi
|
||||
USE_RAW_PROMPT: ${{ inputs.use_raw_prompt }}
|
||||
run: node .github/scripts/claude-task/prepare-claude-prompt.mjs
|
||||
|
||||
- name: Create MCP config
|
||||
run: |
|
||||
@@ -157,11 +115,13 @@ jobs:
|
||||
|
||||
- name: Run Claude
|
||||
id: claude
|
||||
continue-on-error: true
|
||||
uses: anthropics/claude-code-action@1b8ee3b94104046d71fde52ec3557651ad8c0d71 # v1
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ github.token }}
|
||||
github_token: ${{ steps.generate_token.outputs.token }}
|
||||
prompt: ${{ env.CLAUDE_PROMPT }}
|
||||
show_full_output: true
|
||||
settings: |
|
||||
{
|
||||
"permissions": {
|
||||
@@ -183,93 +143,49 @@ jobs:
|
||||
}
|
||||
}
|
||||
claude_args: |
|
||||
--model ${{ inputs.model }} --mcp-config /tmp/mcp-config.json
|
||||
--model ${{ inputs.model }} --max-turns ${{ inputs.max_turns }} --mcp-config /tmp/mcp-config.json
|
||||
|
||||
- name: Extract PR title
|
||||
- name: Push branch
|
||||
if: always()
|
||||
run: |
|
||||
# Use the last commit message as PR title
|
||||
PR_TITLE=$(git log -1 --format='%s' 2>/dev/null | head -1)
|
||||
# Strip Co-authored-by suffix if present
|
||||
PR_TITLE="${PR_TITLE%%[Cc]o-[Aa]uthored-[Bb]y:*}"
|
||||
PR_TITLE="${PR_TITLE%% }"
|
||||
if [ -z "$PR_TITLE" ]; then
|
||||
PR_TITLE="chore: Claude automated task (run ${{ github.run_id }})"
|
||||
fi
|
||||
echo "PR_TITLE=$PR_TITLE" >> "$GITHUB_ENV"
|
||||
echo "Extracted PR title: $PR_TITLE"
|
||||
|
||||
- name: Push branch and create PR
|
||||
env:
|
||||
INPUT_TASK: ${{ inputs.task }}
|
||||
TRIGGERED_BY: ${{ github.actor }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
if ! git diff --quiet "${{ inputs.ref }}"; then
|
||||
# Push using github.token (has write access via workflow permissions)
|
||||
git remote set-url origin "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git"
|
||||
if ! git diff --quiet "${{ inputs.ref }}" 2>/dev/null; then
|
||||
git push -u origin "$BRANCH_NAME"
|
||||
|
||||
if [ "${{ inputs.createPr }}" = "true" ]; then
|
||||
# Switch to PAT for PR creation (so PR is authored by the user)
|
||||
export GH_TOKEN="${{ inputs.user_token }}"
|
||||
if [ -z "${{ inputs.user_token }}" ]; then
|
||||
echo "::error::createPr is enabled but user_token was not provided. A personal access token is required to create PRs under your identity."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## Summary"
|
||||
echo "Automated task completed by Claude."
|
||||
echo ""
|
||||
echo "### Task Description"
|
||||
echo "$INPUT_TASK"
|
||||
echo ""
|
||||
echo "### Generated by"
|
||||
echo "[Workflow Run #${{ github.run_id }}]($RUN_URL)"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "*Triggered by @$TRIGGERED_BY via the Claude Task Runner workflow.*"
|
||||
} > /tmp/pr-body.md
|
||||
|
||||
EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --base "${{ inputs.ref }}" --json number --jq '.[0].number')
|
||||
if [ -n "$EXISTING_PR" ]; then
|
||||
gh pr edit "$EXISTING_PR" --title "$PR_TITLE" --body-file /tmp/pr-body.md
|
||||
echo "::notice::Updated existing PR #$EXISTING_PR"
|
||||
else
|
||||
gh pr create --title "$PR_TITLE" --body-file /tmp/pr-body.md --base "${{ inputs.ref }}"
|
||||
fi
|
||||
else
|
||||
echo "::notice::PR creation is disabled. Changes pushed to branch but no PR created."
|
||||
fi
|
||||
echo "::notice::Changes pushed to branch $BRANCH_NAME"
|
||||
else
|
||||
echo "::notice::No changes made by Claude. Skipping PR creation."
|
||||
echo "::notice::No changes to push."
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
env:
|
||||
INPUT_TASK: ${{ inputs.task }}
|
||||
CLAUDE_OUTCOME: ${{ steps.claude.outcome }}
|
||||
CLAUDE_SESSION_ID: ${{ steps.claude.outputs.session_id }}
|
||||
run: |
|
||||
{
|
||||
echo "## Claude Task Runner Summary"
|
||||
echo ""
|
||||
echo "**Task:** $INPUT_TASK"
|
||||
echo "**Branch:** $BRANCH_NAME"
|
||||
echo "**Branch:** ${BRANCH_NAME:-N/A}"
|
||||
echo "**Claude outcome:** $CLAUDE_OUTCOME"
|
||||
if [ -n "$CLAUDE_SESSION_ID" ]; then
|
||||
echo "**Session ID:** \`$CLAUDE_SESSION_ID\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Call resume url
|
||||
if: inputs.resumeUrl != ''
|
||||
if: always() && inputs.resumeUrl != ''
|
||||
env:
|
||||
RESUME_URL: ${{ inputs.resumeUrl }}
|
||||
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
|
||||
CLAUDE_OUTCOME: ${{ steps.claude.outcome }}
|
||||
CLAUDE_SESSION_ID: ${{ steps.claude.outputs.session_id }}
|
||||
run: node .github/scripts/claude-task/resume-callback.mjs
|
||||
|
||||
- name: Check final status
|
||||
if: always()
|
||||
run: |
|
||||
# Extract only the result message (last element) from Claude's output
|
||||
if [ -f "$EXECUTION_FILE" ]; then
|
||||
jq --arg branch "$BRANCH_NAME" \
|
||||
'{success: true, branch: $branch, result: .[-1]}' "$EXECUTION_FILE" \
|
||||
| curl -X POST "${{ inputs.resumeUrl }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @-
|
||||
else
|
||||
curl -X POST "${{ inputs.resumeUrl }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"success\": true, \"branch\": \"$BRANCH_NAME\", \"result\": null}"
|
||||
if [ "${{ steps.claude.outcome }}" = "failure" ]; then
|
||||
echo "::error::Claude task failed. Check the 'Run Claude' step logs for details."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user