# GitHub Actions CI/CD Pipeline for Test Execution # Generated by BMad TEA Agent - Test Architect Module # Optimized for: Parallel Sharding, Burn-In Loop # Stack: {test_stack_type} | Framework: {test_framework} # # Variables to customize per project: # INSTALL_CMD - dependency install command (e.g., npm ci, pnpm install --frozen-lockfile, yarn --frozen-lockfile) # TEST_CMD - main test command (e.g., npm run test:e2e, npm test, npx vitest) # LINT_CMD - lint command (e.g., npm run lint) # BROWSER_INSTALL - browser install command (frontend/fullstack only; omit for backend) # BROWSER_CACHE_PATH - browser cache path (frontend/fullstack only; omit for backend) name: Test Pipeline on: push: branches: [main, develop] pull_request: branches: [main, develop] schedule: # Weekly burn-in on Sundays at 2 AM UTC - cron: "0 2 * * 0" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: # Lint stage - Code quality checks lint: name: Lint runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Determine Node version id: node-version run: | if [ -f .nvmrc ]; then echo "value=$(cat .nvmrc)" >> "$GITHUB_OUTPUT" echo "Using Node from .nvmrc" else echo "value=24" >> "$GITHUB_OUTPUT" echo "Using default Node 24 (current LTS)" fi - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.value }} cache: "npm" - name: Install dependencies run: npm ci # Replace with INSTALL_CMD - name: Run linter run: npm run lint # Replace with LINT_CMD # Test stage - Parallel execution with sharding test: name: Test (Shard ${{ matrix.shard }}) runs-on: ubuntu-latest timeout-minutes: 30 needs: lint strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 - name: Determine Node version id: node-version run: | if [ -f .nvmrc ]; then echo "value=$(cat .nvmrc)" >> "$GITHUB_OUTPUT" echo "Using Node from .nvmrc" else echo "value=22" >> "$GITHUB_OUTPUT" echo "Using default Node 22 (current LTS)" fi - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.value }} cache: "npm" - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-playwright- - name: Install dependencies run: npm ci # Replace with INSTALL_CMD # Frontend/Fullstack only — remove this step for backend-only stacks - name: Install Playwright browsers run: npx playwright install --with-deps chromium # Replace with BROWSER_INSTALL - name: Run tests (shard ${{ matrix.shard }}/4) run: npm run test:e2e -- --shard=${{ matrix.shard }}/4 # Replace with TEST_CMD + shard args - name: Upload test results if: failure() uses: actions/upload-artifact@v4 with: name: test-results-${{ matrix.shard }} path: | test-results/ playwright-report/ retention-days: 30 # Burn-in stage - Flaky test detection burn-in: name: Burn-In (Flaky Detection) runs-on: ubuntu-latest timeout-minutes: 60 needs: test # Only run burn-in on PRs to main/develop or on schedule if: github.event_name == 'pull_request' || github.event_name == 'schedule' steps: - uses: actions/checkout@v4 - name: Determine Node version id: node-version run: | if [ -f .nvmrc ]; then echo "value=$(cat .nvmrc)" >> "$GITHUB_OUTPUT" echo "Using Node from .nvmrc" else echo "value=22" >> "$GITHUB_OUTPUT" echo "Using default Node 22 (current LTS)" fi - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: ${{ steps.node-version.outputs.value }} cache: "npm" # Frontend/Fullstack only — remove this step for backend-only stacks - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright # Replace with BROWSER_CACHE_PATH key: ${{ runner.os }}-playwright-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies run: npm ci # Replace with INSTALL_CMD # Frontend/Fullstack only — remove this step for backend-only stacks - name: Install Playwright browsers run: npx playwright install --with-deps chromium # Replace with BROWSER_INSTALL # Note: Burn-in targets UI flakiness. For backend-only stacks, remove this job entirely. - name: Run burn-in loop (10 iterations) run: | echo "🔥 Starting burn-in loop - detecting flaky tests" for i in {1..10}; do echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "🔥 Burn-in iteration $i/10" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" npm run test:e2e || exit 1 # Replace with TEST_CMD done echo "✅ Burn-in complete - no flaky tests detected" - name: Upload burn-in failure artifacts if: failure() uses: actions/upload-artifact@v4 with: name: burn-in-failures path: | test-results/ playwright-report/ retention-days: 30 # Report stage - Aggregate and publish results report: name: Test Report runs-on: ubuntu-latest needs: [test, burn-in] if: always() steps: - name: Download all artifacts uses: actions/download-artifact@v4 with: path: artifacts - name: Generate summary run: | echo "## Test Execution Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- **Status**: ${{ needs.test.result }}" >> $GITHUB_STEP_SUMMARY echo "- **Burn-in**: ${{ needs.burn-in.result }}" >> $GITHUB_STEP_SUMMARY echo "- **Shards**: 4" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ "${{ needs.burn-in.result }}" == "failure" ]; then echo "⚠️ **Flaky tests detected** - Review burn-in artifacts" >> $GITHUB_STEP_SUMMARY fi # ============================================================================ # EXTENSION PATTERNS — Script Injection Prevention # ============================================================================ # When extending this template into reusable workflows, manual dispatch # workflows, or composite actions, NEVER use ${{ inputs.* }} directly in # run: blocks. Always pass through env: intermediaries. # # KEY PRINCIPLE: Inputs must be DATA, not COMMANDS. # Pass inputs through env: and interpolate as quoted arguments into fixed # commands. NEVER accept command-shaped inputs (e.g., install-command, # test-command) that get executed as shell code — even through env:. # # --- Reusable Workflow (workflow_call) --- # # on: # workflow_call: # inputs: # test-grep: # description: 'Test grep filter (data only — not a command)' # type: string # required: false # default: '' # base-ref: # description: 'Base branch for diff' # type: string # required: false # default: 'main' # burn-in-count: # description: 'Number of burn-in iterations' # type: string # required: false # default: '10' # # jobs: # test: # runs-on: ubuntu-latest # steps: # - uses: actions/checkout@v4 # # Fixed command — not derived from inputs # - name: Install dependencies # run: npm ci # # ✅ SAFE — input is DATA passed as an argument to a fixed command # - name: Run tests # env: # TEST_GREP: ${{ inputs.test-grep }} # run: | # # Security: inputs passed through env: to prevent script injection # if [ -n "$TEST_GREP" ]; then # npx playwright test --grep "$TEST_GREP" # else # npx playwright test # fi # # --- Manual Dispatch (workflow_dispatch) --- # # on: # workflow_dispatch: # inputs: # test-grep: # description: 'Test grep filter (data only — not a command)' # type: string # required: false # environment: # description: 'Target environment' # type: choice # options: [staging, production] # # jobs: # run-tests: # runs-on: ubuntu-latest # steps: # - uses: actions/checkout@v4 # # ✅ SAFE — input is DATA interpolated into a fixed command # - name: Run selected tests # env: # TEST_GREP: ${{ inputs.test-grep }} # run: | # # Security: inputs passed through env: to prevent script injection # npx playwright test --grep "$TEST_GREP" # # --- Composite Action (action.yml) --- # # inputs: # test-grep: # description: 'Test grep filter (data only — not a command)' # required: false # default: '' # burn-in-count: # description: 'Number of burn-in iterations' # required: false # default: '10' # # runs: # using: composite # steps: # # ✅ SAFE — inputs are DATA arguments to fixed commands # - name: Run burn-in # shell: bash # env: # TEST_GREP: ${{ inputs.test-grep }} # BURN_IN_COUNT: ${{ inputs.burn-in-count }} # run: | # # Security: inputs passed through env: to prevent script injection # for i in $(seq 1 "$BURN_IN_COUNT"); do # echo "Burn-in iteration $i/$BURN_IN_COUNT" # npx playwright test --grep "$TEST_GREP" || exit 1 # done # # ❌ NEVER DO THIS: # # Direct ${{ inputs.* }} in run: — GitHub expression injection # - run: npx playwright test --grep "${{ inputs.test-grep }}" # # # Executing input-derived env var as a command — still command injection # - env: # CMD: ${{ inputs.test-command }} # run: $CMD # ============================================================================