ci: Enhance Claude task runner workflow (#26267)

This commit is contained in:
Declan Carroll
2026-02-26 08:30:33 +00:00
committed by GitHub
parent 27949dd87e
commit b4465c86af
3 changed files with 178 additions and 132 deletions
@@ -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);
}
+48 -132
View File
@@ -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