name: Visual diff # Same as preview-smoke — hits the homelab deployment at deckhearth.stillwell.cloud. # Paths are tcg-vault-specific (pages router, JS). on: pull_request: branches: [main] paths: - 'pages/**' # Exclude API-only edits — they don't render UI, so they can't move # any visual-diff pixels. Order matters: GitHub Actions evaluates the # `paths:` list with minimatch and applies `!`-prefixed exclusions # only after they've already matched a prior include. Keep this entry # immediately AFTER `pages/**`. # Surfaced by `tighten-visual-diff-path-filter` after PR #19 # (cors-tighten) and PR #20 (add-rate-limiting) both falsely # triggered Screenshot diff at ~55s/PR. - '!pages/api/**' - 'components/**' - 'styles/**' - 'tailwind.config.js' - 'postcss.config.js' # Baseline-only PRs (specs + committed snapshots) should still run # visual diff now that home.png is in tests/visual/__screenshots__/. - 'tests/visual/**' concurrency: group: visual-diff-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read pull-requests: write jobs: gate: name: Should run? runs-on: [self-hosted, axiom] outputs: should_run: ${{ steps.check.outputs.should_run }} steps: - id: check env: # See preview-smoke.yml for the rationale: ${{ github.event.* }} # inlined into shell is a syntax-error + injection vector. PR_BODY: ${{ github.event.pull_request.body }} PR_IS_FORK: ${{ github.event.pull_request.head.repo.fork }} run: | if [[ "$PR_IS_FORK" == "true" ]]; then echo "should_run=false" >> $GITHUB_OUTPUT echo "::notice::Visual diff skipped on fork PR (bypass secret unavailable to forks)" elif echo "$PR_BODY" | grep -qE 'pipeline:.*skip.*\bvisual\b'; then echo "should_run=false" >> $GITHUB_OUTPUT echo "::notice::Visual diff skipped via pipeline directive" else echo "should_run=true" >> $GITHUB_OUTPUT fi visual: name: Screenshot diff needs: gate if: needs.gate.outputs.should_run == 'true' runs-on: [self-hosted, axiom] timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: Wait for homelab app health run: | BASE_URL="${SMOKE_BASE_URL:-https://deckhearth.stillwell.cloud}" for i in $(seq 1 30); do if curl -fsS "${BASE_URL}/api/health" >/dev/null; then echo "Homelab app healthy at ${BASE_URL}" exit 0 fi echo "Waiting for ${BASE_URL}/api/health (${i}/30)…" sleep 10 done echo "::error::Homelab app did not become healthy in time" exit 1 env: SMOKE_BASE_URL: ${{ vars.SMOKE_BASE_URL }} - uses: actions/setup-node@v4 with: node-version: '20' cache: npm - name: Cache node_modules id: cache-node-modules uses: actions/cache@v4 with: path: node_modules key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }} - run: npm ci if: steps.cache-node-modules.outputs.cache-hit != 'true' # Cache Playwright browsers — see preview-smoke.yml for rationale. - name: Resolve Playwright version id: pw-version run: | VERSION=$(node -p "require('./package-lock.json').packages['node_modules/@playwright/test'].version") echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Cache Playwright browsers id: cache-playwright uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}-chromium - name: Install Playwright browsers run: | if [ "${{ steps.cache-playwright.outputs.cache-hit }}" = "true" ]; then npx playwright install-deps chromium else npx playwright install --with-deps chromium fi # Hard-gating after `harden-visual-diff-gate` brief 2 (PR #140). # The prior `continue-on-error: true` was a documented end state of # `adopt-playwright-smoke` (Decision 4) — necessary while no Linux # baseline existed. PR #58 seeded the first baseline, and PR #139 # refreshed it against post-glass-redesign main. Visual diff is now # a real merge gate. # # If this step fails on a UI-touching PR, two paths: # (a) Intentional change: dispatch # `.github/workflows/seed-visual-baselines.yml` against this # PR's preview URL, merge the resulting baseline-refresh PR, # then re-run this workflow. # (b) Unintentional regression: open the artifact bundle from # this run, inspect the diff PNG, fix the regression in # source, push. - name: Capture screenshots (PR) run: npx playwright test --project=visual --update-snapshots=none env: BASE_URL: ${{ vars.SMOKE_BASE_URL || 'https://deckhearth.stillwell.cloud' }} - name: Upload screenshots + diffs if: always() uses: actions/upload-artifact@v4 with: name: visual-diff path: | tests/visual/__screenshots__/ test-results/ retention-days: 7 - name: Comment on PR with diff link if: always() uses: actions/github-script@v7 with: script: | const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: `## Visual Diff\n\nScreenshots and diffs uploaded as artifacts: [view run](${run})\n\nIf intentional changes: update snapshots locally with \`npx playwright test --project=visual --update-snapshots\` and commit.` });