Skip to content

Instantly share code, notes, and snippets.

@alexanderop
Created December 13, 2025 20:54
Show Gist options
  • Select an option

  • Save alexanderop/464a7a228653e4df27179b9c806b2065 to your computer and use it in GitHub Desktop.

Select an option

Save alexanderop/464a7a228653e4df27179b9c806b2065 to your computer and use it in GitHub Desktop.
# Consolidated Claude QA Workflow
# Replaces both claude-playwright-explore.yml and claude-qa-verify.yml
name: Claude QA
on:
workflow_dispatch:
inputs:
mode:
description: 'QA mode'
type: choice
options:
- explore
- verify
default: 'explore'
pr_number:
description: 'PR number (for posting results)'
required: false
type: string
focus:
description: 'Test focus area'
type: choice
options:
- general
- navigation
- forms
- workout-flow
- security
- accessibility
default: 'general'
pull_request:
types: [labeled]
issue_comment:
types: [created]
jobs:
qa:
# Run on: manual trigger, specific labels, or @claude verify comment
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
contains(fromJson('["claude-explore", "qa-verify"]'), github.event.label.name)) ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '@claude verify'))
runs-on: ubuntu-latest
timeout-minutes: 20
# Prevent duplicate runs on same PR (use ref for workflow_dispatch branch deduplication)
concurrency:
group: claude-qa-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
statuses: write
env:
NODE_VERSION: '22'
QA_FOCUS: ${{ github.event.inputs.focus || 'general' }}
steps:
- name: Determine QA mode
id: qa-mode
shell: bash
run: |
MODE="explore"
if [[ "${{ github.event.label.name }}" == "qa-verify" ]]; then
MODE="verify"
elif [[ "${{ github.event.label.name }}" == "claude-explore" ]]; then
MODE="explore"
elif [[ "${{ github.event_name }}" == "issue_comment" ]]; then
MODE="verify"
elif [[ -n "${{ github.event.inputs.mode }}" ]]; then
MODE="${{ github.event.inputs.mode }}"
fi
echo "mode=$MODE" >> $GITHUB_OUTPUT
echo "QA_MODE=$MODE" >> $GITHUB_ENV
echo "Determined QA mode: $MODE"
- name: Get PR ref for issue_comment
if: github.event_name == 'issue_comment'
id: pr-ref
uses: actions/github-script@v7
with:
script: |
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.issue.number
});
core.setOutput('ref', pr.head.sha);
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ steps.pr-ref.outputs.ref || github.event.pull_request.head.sha || github.sha }}
# Reuse existing composite action instead of duplicating setup
- name: Setup test environment
uses: ./.github/actions/setup-test-env
with:
node-version: ${{ env.NODE_VERSION }}
- name: Get PR context
if: env.QA_MODE == 'verify'
id: pr-context
uses: actions/github-script@v7
env:
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
with:
script: |
let prNumber, prBody, prTitle;
if (context.eventName === 'pull_request') {
prNumber = context.payload.pull_request.number;
prBody = context.payload.pull_request.body || '';
prTitle = context.payload.pull_request.title;
} else if (context.eventName === 'issue_comment') {
// @claude verify comment on PR
prNumber = context.payload.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
prBody = pr.body || '';
prTitle = pr.title;
} else if (process.env.PR_NUMBER_INPUT) {
prNumber = parseInt(process.env.PR_NUMBER_INPUT, 10);
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
prBody = pr.body || '';
prTitle = pr.title;
}
// Fetch linked issues
let linkedIssues = '';
if (prBody) {
const matches = prBody.match(/(?:closes|fixes|resolves)\s+#(\d+)/gi) || [];
for (const match of matches) {
const issueNum = match.match(/\d+/)[0];
try {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(issueNum)
});
linkedIssues += `\n### Issue #${issueNum}: ${issue.title}\n${issue.body || ''}\n`;
} catch (e) {}
}
}
core.setOutput('pr_number', prNumber || '');
core.setOutput('pr_title', prTitle || '');
core.setOutput('pr_body', prBody || '');
core.setOutput('linked_issues', linkedIssues);
- name: Start development server
shell: bash
run: |
pnpm dev --host 0.0.0.0 --port 5173 &
DEV_PID=$!
echo "DEV_PID=$DEV_PID" >> $GITHUB_ENV
# Early failure detection - check process is still alive after startup
sleep 2
if ! kill -0 "$DEV_PID" 2>/dev/null; then
echo "Dev server failed to start immediately"
exit 1
fi
echo "Waiting for dev server with app mounted..."
for i in {1..90}; do
# Check for HTTP 200 AND expected content (more robust pattern)
if curl -sf http://localhost:5173 | grep -qE '(id="app"|<title>)'; then
echo "Dev server ready!"
exit 0
fi
# Also check process is still running during wait
if ! kill -0 "$DEV_PID" 2>/dev/null; then
echo "Dev server process died during startup"
exit 1
fi
sleep 1
done
echo "Dev server failed to start properly"
curl -v http://localhost:5173 || true
exit 1
- name: Load QA prompts
id: load-prompts
shell: bash
env:
PR_BODY_RAW: ${{ steps.pr-context.outputs.pr_body }}
LINKED_ISSUES_RAW: ${{ steps.pr-context.outputs.linked_issues }}
run: |
# Get today's date
TODAY=$(date +%Y-%m-%d)
# Start building combined prompt with system prompt
{
echo 'prompt<<PROMPT_EOF'
# Add system prompt (personality & rules)
cat .claude/prompts/qa-system-prompt.md
echo ""
echo "---"
echo ""
# Add task prompt based on mode and focus
if [[ "${{ env.QA_MODE }}" == "verify" ]]; then
TASK_CONTENT=$(cat .claude/prompts/qa-verify.md)
elif [[ "${{ env.QA_FOCUS }}" == "security" ]]; then
TASK_CONTENT=$(cat .claude/prompts/qa-focus-security.md)
elif [[ "${{ env.QA_FOCUS }}" == "accessibility" ]]; then
TASK_CONTENT=$(cat .claude/prompts/qa-focus-a11y.md)
else
TASK_CONTENT=$(cat .claude/prompts/qa-explore.md)
fi
# Replace simple placeholders
TASK_CONTENT="${TASK_CONTENT//\{\{APP_URL\}\}/http://localhost:5173}"
TASK_CONTENT="${TASK_CONTENT//\{\{QA_FOCUS\}\}/${{ env.QA_FOCUS }}}"
TASK_CONTENT="${TASK_CONTENT//\{\{DATE\}\}/$TODAY}"
# For verify mode, add PR context
if [[ "${{ env.QA_MODE }}" == "verify" ]]; then
TASK_CONTENT="${TASK_CONTENT//\{\{PR_NUMBER\}\}/${{ steps.pr-context.outputs.pr_number }}}"
TASK_CONTENT="${TASK_CONTENT//\{\{PR_TITLE\}\}/${{ steps.pr-context.outputs.pr_title }}}"
# Use bash parameter expansion for multiline replacements (sed breaks on newlines)
TASK_CONTENT="${TASK_CONTENT//\{\{PR_BODY\}\}/$PR_BODY_RAW}"
TASK_CONTENT="${TASK_CONTENT//\{\{LINKED_ISSUES\}\}/$LINKED_ISSUES_RAW}"
fi
echo "$TASK_CONTENT"
echo 'PROMPT_EOF'
} >> $GITHUB_OUTPUT
- name: Run Claude QA - Explore Mode
if: env.QA_MODE == 'explore'
id: claude-explore
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ steps.load-prompts.outputs.prompt }}
# QA tester tools only - no source code access, no low-level JS execution
# A real QA tester only interacts through the UI
claude_args: |
--max-turns 60
--mcp-config '{"mcpServers":{"playwright":{"command":"npx","args":["@playwright/mcp@latest","--headless","--isolated","--caps=vision,testing"]}}}'
--allowedTools "mcp__playwright__browser_navigate,mcp__playwright__browser_navigate_back,mcp__playwright__browser_click,mcp__playwright__browser_type,mcp__playwright__browser_snapshot,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_console_messages,mcp__playwright__browser_wait_for,mcp__playwright__browser_hover,mcp__playwright__browser_resize,mcp__playwright__browser_drag,mcp__playwright__browser_select_option,mcp__playwright__browser_press_key,mcp__playwright__browser_tabs,mcp__playwright__browser_file_upload,mcp__playwright__browser_fill_form,mcp__playwright__browser_handle_dialog,mcp__playwright__browser_close,mcp__playwright__browser_verify_element_visible,mcp__playwright__browser_verify_text_visible,mcp__playwright__browser_verify_value,Write"
- name: Run Claude QA - Verify Mode
if: env.QA_MODE == 'verify'
id: claude-verify
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ steps.load-prompts.outputs.prompt }}
# QA tester tools only - no source code access, no low-level JS execution
# A real QA tester only interacts through the UI
claude_args: |
--max-turns 100
--mcp-config '{"mcpServers":{"playwright":{"command":"npx","args":["@playwright/mcp@latest","--headless","--isolated","--caps=vision,testing"]}}}'
--allowedTools "mcp__playwright__browser_navigate,mcp__playwright__browser_navigate_back,mcp__playwright__browser_click,mcp__playwright__browser_type,mcp__playwright__browser_snapshot,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_console_messages,mcp__playwright__browser_wait_for,mcp__playwright__browser_hover,mcp__playwright__browser_resize,mcp__playwright__browser_drag,mcp__playwright__browser_select_option,mcp__playwright__browser_press_key,mcp__playwright__browser_tabs,mcp__playwright__browser_file_upload,mcp__playwright__browser_fill_form,mcp__playwright__browser_handle_dialog,mcp__playwright__browser_close,mcp__playwright__browser_verify_element_visible,mcp__playwright__browser_verify_text_visible,mcp__playwright__browser_verify_value,Write"
- name: Check report exists
id: check-report
if: always()
shell: bash
run: |
if [ -f "qa-report.md" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Report generated successfully"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Warning: No report generated"
fi
# Retry with full tools if main run failed to generate a report
- name: Retry if no report generated
if: steps.check-report.outputs.exists == 'false'
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
IMPORTANT: The previous QA run did not generate a report.
Do a quick navigation smoke test and write qa-report.md immediately:
1. Navigate to http://localhost:5173
2. Check 3-4 main pages load correctly
3. Write the report with what you observed
Write `qa-report.md` now with:
- Summary of pages tested
- Any errors found
- Verdict: ✅ HEALTHY / ⚠️ MINOR ISSUES / ❌ CRITICAL BUGS
claude_args: |
--max-turns 15
--mcp-config '{"mcpServers":{"playwright":{"command":"npx","args":["@playwright/mcp@latest","--headless","--isolated","--caps=vision,testing"]}}}'
--allowedTools "mcp__playwright__browser_navigate,mcp__playwright__browser_click,mcp__playwright__browser_type,mcp__playwright__browser_snapshot,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_console_messages,Write"
- name: Verify report after retry
id: check-report-retry
if: steps.check-report.outputs.exists == 'false'
shell: bash
run: |
if [ -f "qa-report.md" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Set commit status
id: qa-status
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let state = 'success';
let description = 'QA passed';
try {
const report = fs.readFileSync('qa-report.md', 'utf8');
if (report.includes('❌ NEEDS FIXES') || report.includes('❌ CRITICAL')) {
state = 'failure';
description = 'QA found critical issues';
} else if (report.includes('⚠️')) {
state = 'pending';
description = 'QA found minor issues';
} else if (report.includes('✅ APPROVED') || report.includes('✅ HEALTHY')) {
state = 'success';
description = 'QA passed';
}
} catch (e) {
state = 'error';
description = 'QA report not generated';
}
// Use PR head sha for issue_comment, otherwise use context.sha
const sha = '${{ steps.pr-ref.outputs.ref }}' || context.sha;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: sha,
state: state,
context: 'Claude QA',
description: description,
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
});
// Set output for job failure
core.setOutput('qa_state', state);
- name: Post results to PR
if: |
always() &&
(github.event.pull_request.number || github.event.issue.number || github.event.inputs.pr_number || steps.pr-context.outputs.pr_number)
uses: actions/github-script@v7
env:
# Avoid script injection by passing inputs via environment variables
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
PR_NUMBER_CONTEXT: ${{ steps.pr-context.outputs.pr_number }}
QA_MODE: ${{ env.QA_MODE }}
with:
script: |
const fs = require('fs');
const prNumber = context.payload.pull_request?.number ||
context.payload.issue?.number ||
process.env.PR_NUMBER_INPUT ||
process.env.PR_NUMBER_CONTEXT;
if (!prNumber) return;
let qaReport = '';
try {
qaReport = fs.readFileSync('qa-report.md', 'utf8');
} catch (e) {
qaReport = '⚠️ QA report not generated. Check [workflow logs](' +
`${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`;
}
const mode = process.env.QA_MODE === 'verify' ? 'Verification' : 'Exploration';
const focus = process.env.QA_FOCUS || 'general';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = [
`## 🔍 Claude QA ${mode} Report`,
'',
`**Mode**: ${mode} | **Focus**: ${focus}`,
`**Run**: [View Logs](${runUrl})`,
'',
'---',
'',
qaReport,
'',
'---',
'_Automated QA by Claude Code_'
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: body
});
- name: Upload QA artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: qa-artifacts-${{ github.run_id }}
path: |
qa-report.md
bug-*.png
if-no-files-found: ignore
- name: Remove trigger label
if: |
github.event_name == 'pull_request' &&
contains(fromJson('["claude-explore", "qa-verify"]'), github.event.label.name)
uses: actions/github-script@v7
env:
LABEL_NAME: ${{ github.event.label.name }}
with:
script: |
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
name: process.env.LABEL_NAME
});
} catch (e) {
// Only suppress 404 (label not found), warn on other errors
if (e.status !== 404) {
core.warning(`Failed to remove label: ${e.message}`);
}
}
- name: Cleanup
if: always()
shell: bash
run: kill $DEV_PID 2>/dev/null || true
- name: Job summary
if: always()
shell: bash
run: |
echo "## Claude QA Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Mode**: ${{ env.QA_MODE }}" >> $GITHUB_STEP_SUMMARY
echo "- **Focus**: ${{ env.QA_FOCUS }}" >> $GITHUB_STEP_SUMMARY
echo "- **Report generated**: ${{ steps.check-report.outputs.exists }}" >> $GITHUB_STEP_SUMMARY
echo "- **QA Result**: ${{ steps.qa-status.outputs.qa_state }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Estimated Cost" >> $GITHUB_STEP_SUMMARY
echo "- Claude API: ~\$0.20-0.40" >> $GITHUB_STEP_SUMMARY
echo "- GitHub Actions: ~\$0.05" >> $GITHUB_STEP_SUMMARY
- name: Fail job if QA found issues
if: steps.qa-status.outputs.qa_state == 'failure'
shell: bash
run: |
echo "❌ QA found critical issues - failing the pipeline"
exit 1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment