deckhearth/.github/workflows/visual-diff.yml
varutasu f228c096fd
convoy: migrate CI to self-hosted axiom runners (briefs 1+2) (#132)
* convoy: migrate CI to self-hosted axiom runners (briefs 1+2)

Moves 4 of 5 GitHub Actions workflows from `ubuntu-latest` to the new
`stwl-labs` org-level self-hosted pool (CT 111 axiom-runner-1..4) and
rewires the `migrate` job to use CT 102's shared Postgres via per-run
databases.

Changes:
- ci.yml: lint, schema-map-fresh, forbidden-patterns, migrate, test ->
  `[self-hosted, axiom]`. migrate job drops `services.postgres` (saved
  ~30s/run of image pull) and switches to `HOMELAB_CI_POSTGRES_BASE_URL`
  secret + per-run DB (`ci_run_<run_id>_<run_attempt>`) with `always()`
  cleanup so failed migrations don't leak DBs.
- preview-smoke.yml: gate + smoke -> self-hosted. Playwright browser
  cache lives under /opt/appdata/gha-runner/shared-cache/playwright on
  the host bind mount; first PR primes it, subsequent runs reuse.
- visual-diff.yml: gate + visual -> self-hosted (same Playwright cache).
- pr-health-rollup.yml: rollup -> self-hosted.
- agent-context-drift.yml: deliberately LEFT on ubuntu-latest (D4 in
  convoy doc). Weekly cron stays GitHub-hosted so it runs even when
  axiom is down.

Why on this side and not the runner side:
- migrate adds an explicit `sudo apt-get install -y postgresql-client`
  step (~10s, amortized via apt-cache survival). The runner image
  doesn't ship psql; baking it in would require a custom image and
  doesn't earn its keep for one job.

Repo prereqs (set before this PR opens):
- `HOMELAB_CI_POSTGRES_BASE_URL` repo secret set (value pattern:
  `postgres://deckhearth_ci:<pw>@192.168.68.102:5432`)
- `deckhearth_ci` Postgres user created on CT 102 with CREATEDB,
  no superuser
- stwl-labs org Actions settings: "Require approval for all outside
  collaborators" + runner group rejects public repos
- 4 runners online: `axiom-runner-1..4`, status Idle

Follow-ups (per convoy):
- Brief 3: forbidden-pattern gate to catch `runs-on: ubuntu-latest`
  re-introduction outside the agent-context-drift allowlist
- Brief 4: AGENTS.md updates + 1-line revert path (D5)
- Weekly cron on CT 102 to GC any `ci_run_*` DBs older than 7d
  (Risk #4 mitigation)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(migrate): use PGHOST/PGUSER/PGPASSWORD instead of URL secret

First Brief 1+2 validation run failed on the migrate job with
`psql: invalid option -- '/'` despite the secret being set correctly
and a direct CT-111 → CT-102 psql connection working fine. The
URL-parse path in `psql "$PGBASE/postgres"` was the fragile bit.

Splitting the connection into discrete `PG*` env vars (which psql
picks up automatically) sidesteps URL parsing entirely. The
`HOMELAB_CI_POSTGRES_BASE_URL` repo secret is now
`HOMELAB_CI_POSTGRES_PASSWORD` — password only — and the workflow
hardcodes the (non-sensitive) host/port/user. `node-pg-migrate`
still reads `POSTGRES_URL` from `.env.local`, so we assemble that
URL inline for it; the runner is ephemeral so the leaked-to-disk
password is bounded to one job.

Convoy doc updated to reflect the shipped approach + lesson learned
in prerequisites.

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: trigger vercel preview after stwl-labs reauth

Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: re-test playwright after vercel project rebind

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 23:46:31 -05:00

149 lines
5.5 KiB
YAML

name: Visual diff
# Same as preview-smoke — waits for Vercel's Preview deployment, then captures
# Playwright screenshots against it. UI-paths-only trigger to keep cost down.
# 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
# `deployments: read` + `statuses: read` are required by wait-for-vercel-preview
# (see preview-smoke.yml for context). `pull-requests: write` is required by the
# final github-script step that posts the "Visual Diff" comment back to the PR;
# without it the API returns 403 even though the screenshots upload fine.
permissions:
contents: read
deployments: read
pull-requests: write
statuses: read
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 Vercel Preview deployment
id: vercel
uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 120
# See preview-smoke.yml for the no-`set-bypass-cookie` rationale.
path: /?x-vercel-protection-bypass=${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
- 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
- name: Capture screenshots (PR)
run: npx playwright test --project=visual --update-snapshots=none
env:
BASE_URL: ${{ steps.vercel.outputs.url }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
continue-on-error: true
- 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.`
});