# Mutation testing (L3) — nightly run + on-demand. ADR-020. # # Stryker is slow (~minutes per feature) so it's NOT part of the default # CI loop. This workflow runs nightly (and on manual dispatch) across every # feature with a stryker.config.json, then uploads the HTML + JSON # mutation reports as artifacts. # # On a meaningful score drop (>5%) it opens a tracking issue. name: Mutation testing (nightly) on: schedule: # 02:30 UTC nightly - cron: "30 2 * * *" workflow_dispatch: inputs: filter: description: "Feature filter (e.g. @repo/auth). Empty = all features." required: false default: "" permissions: contents: read issues: write jobs: mutate: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres POSTGRES_DB: cms_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 timeout-minutes: 60 steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 9 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - name: Run mutation testing env: DATABASE_URL: postgres://postgres:postgres@localhost:5432/cms_test PAYLOAD_SECRET: test-secret-do-not-use-in-prod run: | if [ -n "${{ inputs.filter }}" ]; then pnpm mutate -- --filter "${{ inputs.filter }}" else pnpm mutate fi continue-on-error: true - name: Upload mutation reports if: always() uses: actions/upload-artifact@v4 with: name: mutation-reports path: packages/*/reports/mutation/ retention-days: 30 - name: Open tracking issue on >5% score drop if: failure() uses: actions/github-script@v7 with: script: | const fs = require('fs'); const path = require('path'); const reports = []; const pkgsDir = path.join(process.cwd(), 'packages'); if (fs.existsSync(pkgsDir)) { for (const pkg of fs.readdirSync(pkgsDir)) { const json = path.join(pkgsDir, pkg, 'reports', 'mutation', 'mutation.json'); if (fs.existsSync(json)) { try { const data = JSON.parse(fs.readFileSync(json, 'utf8')); const score = data.thresholds?.high && data.systemUnderTestMetrics?.metrics?.mutationScore; if (typeof score === 'number') { reports.push({ pkg, score: score.toFixed(2) }); } } catch { /* skip */ } } } } if (reports.length === 0) return; const body = [ 'Nightly mutation testing run flagged failures. Latest scores:', '', ...reports.map(r => `- **${r.pkg}**: ${r.score}%`), '', `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, ].join('\n'); await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: `Mutation score drop — ${new Date().toISOString().slice(0, 10)}`, body, labels: ['mutation-testing', 'automated'], });