-
-
Save alexanderop/464a7a228653e4df27179b9c806b2065 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 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