deckhearth/.github/workflows/seed-visual-baselines.yml
Randall Stillwell ac91d73a34 convoy: flip visual-diff to a hard merge gate (harden-visual-diff-gate brief 2/2)
Removes `continue-on-error: true` from `.github/workflows/visual-diff.yml`'s
`Capture screenshots (PR)` step. Visual drift is now a real merge gate
on UI-touching PRs.

Brief 2/2 of the `harden-visual-diff-gate` convoy. PR #138 shipped the
seed workflow (Brief 1); PR #139 (`54495fe`) landed the fresh Linux
baseline regenerated against post-glass-redesign main on CT 111. With a
known-good baseline committed, the gate can flip without false-failing
every UI-touching PR.

## What changes

- `.github/workflows/visual-diff.yml` — drop the
  `continue-on-error: true` flag; add an inline rationale block linking
  to the convoy + the operator runbook for both intentional changes
  (dispatch seed workflow → manually open PR → merge → re-run) and
  unintentional regressions (inspect artifact diff → fix → push).
- `.github/workflows/ci.yml` — add 9th `forbidden-patterns` check that
  greps `visual-diff.yml` for `^\s*continue-on-error:\s*true` and fails
  the build if it returns. Risk #3 of the convoy made concrete: prevents
  silent re-introduction via template revert. Scoped narrowly to that
  one file; other workflows (`seed-visual-baselines.yml`'s PR-open
  step, etc.) legitimately use the flag. Job name bumped from
  "Forbidden patterns (8 checks)" → "(9 checks)". All `Check N/8`
  group labels renumbered to `N/9`.
- `AGENTS.md` — § Testing § Visual baselines rewritten to drop the
  "Known staleness as of 2026-06-12" callout (resolved by PR #139);
  § Testing § Screenshot diff rewritten to lead with "hard merge gate",
  document the intentional-change runbook, reference the new ci.yml
  check, and explicitly mention the org-setting caveat for the seed
  workflow's auto-PR step.
- `tests/visual/homepage.spec.ts` — module docblock rewritten to match
  the AGENTS.md runbook: drops the "advisory, not gating" language;
  promotes the seed-visual-baselines workflow as the primary
  re-seeding path; demotes the Playwright Docker image to the offline
  fallback.
- `.github/workflows/seed-visual-baselines.yml` — patches the
  `peter-evans/create-pull-request@v6` PR-open failure case discovered
  during Brief 1's first dispatch (run 27454132468). The PR-open step
  is now `continue-on-error: true` (narrowly scoped, with an inline
  rationale callout distinguishing it from the just-removed
  `visual-diff.yml` flag — that one silently hid real UI regressions;
  this one fronts a known org-level "Allow GitHub Actions to create
  and approve pull requests" limitation with a loud failure notice).
  New steps disambiguate the three possible outcomes (no-changes /
  pr-opened / branch-pushed-pr-blocked) via a `git ls-remote` check on
  the bot branch and exit non-zero on the blocked-PR case so the
  workflow run shows red and the operator gets the exact `gh pr create`
  command in the run logs.
- `.convoys/harden-visual-diff-gate.md` — status: shipping; Step 2
  marked SHIPPED; Decision D4 ratified (chose option C: accept org
  setting, document manual `gh pr create` fallback). Inline links to
  PR #139 + PR #140.

## Test plan

- [x] `npm run lint` — clean (1 pre-existing unrelated warning)
- [x] `npm run test:run` — 24 files / 118 tests pass
- [ ] CI on this PR: 9th forbidden-patterns check passes; visual-diff
  job passes against the fresh baseline; convoy-metrics-gate passes
  (2 new rows added by this commit)
- [ ] After merge: smoke test the 9th check by opening a throwaway PR
  that re-adds `continue-on-error: true` to `visual-diff.yml`; confirm
  it red-X's. (Skip if confident in the grep.)

## Convoy state

- Brief 1: SHIPPED (PR #138, `c100c5f`, 2026-06-13)
- Baseline refresh: SHIPPED (PR #139, `54495fe`, 2026-06-13)
- Brief 2 (this PR): shipping
- Convoy closeout: this PR's merge

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 21:58:54 -05:00

218 lines
9.6 KiB
YAML

name: Seed visual baselines
# Brief 1 of the `harden-visual-diff-gate` convoy.
#
# Repeatable Linux baseline regeneration for `tests/visual/__screenshots__/`.
# Runs on the self-hosted axiom runner (CT 111) so the resulting PNGs are
# byte-equivalent to what `visual-diff.yml` will see during PR comparisons —
# this is the same toolchain (`myoung34/github-runner` image + Chromium
# version pinned by `package-lock.json`).
#
# Dispatch:
# - GitHub UI → Actions → Seed visual baselines → Run workflow
# - Or via gh CLI:
# gh workflow run seed-visual-baselines.yml \
# -f base_url=https://<preview-or-prod>.vercel.app \
# -f reason="Glass redesign briefs landed — refresh"
#
# Output: if baselines changed, a `chore(visual): ...` PR is opened with
# the updated `tests/visual/__screenshots__/*.png` for human review. If
# the new captures match the existing committed baselines, no PR is
# opened (workflow exits with a `::notice::` annotation).
#
# Why not a Mac dev workflow? `playwright.config.js`'s
# `snapshotPathTemplate` has no `{platform}` token, so a Mac-generated
# PNG silently overwrites the canonical Linux baseline and the next CI
# diff will fail against it. See AGENTS.md § Visual baselines.
on:
workflow_dispatch:
inputs:
base_url:
description: 'URL to capture baselines against (production or a long-lived preview). Required — no default to avoid wrong-target accidents.'
required: true
reason:
description: 'Short reason for the refresh (appears in the resulting PR body).'
required: false
default: 'Periodic baseline refresh'
permissions:
contents: write
pull-requests: write
jobs:
seed:
name: Capture + commit new baselines
runs-on: [self-hosted, axiom]
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
# Same caching shape as ci.yml / visual-diff.yml so the baseline-seed
# run reuses the existing CT 111 cache mounts.
- 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'
- 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
# `--update-snapshots` writes new PNGs to `tests/visual/__screenshots__/`
# but does NOT fail the run when a baseline mismatches — that's the
# point. The diff-gate hardening (Brief 2) makes the matching
# `visual-diff.yml` invocation fail-on-mismatch.
- name: Capture + write baselines
env:
BASE_URL: ${{ inputs.base_url }}
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
run: npx playwright test --project=visual --update-snapshots
# `peter-evans/create-pull-request@v6` handles branch creation,
# commit, push, and PR open/update in a single idempotent step. If
# there are no changes to `tests/visual/__screenshots__/`, it
# short-circuits and leaves no branch/PR behind.
#
# KNOWN LIMITATION (encountered in workflow run 27454132468, first
# invocation 2026-06-13): the PR-open sub-step fails with
# "GitHub Actions is not permitted to create or approve pull requests"
# because stwl-labs has the org-level "Allow GitHub Actions to
# create and approve pull requests" setting disabled (Settings →
# Actions → General → Workflow permissions). The branch IS pushed
# successfully even when this fails — so the operator can open the
# PR manually:
#
# gh pr create --base main \
# --head bot/visual-baselines-<run_id> \
# --title "chore(visual): refresh baselines from <url>" \
# --body "..."
#
# `continue-on-error: true` on this step is intentional and scoped:
# it lets the workflow as a whole succeed when the branch push
# works but the PR-open step is blocked by the org setting. The
# follow-up step below tells the operator exactly what to run.
# This is NOT the same `continue-on-error` as the one we just
# removed from `visual-diff.yml`'s screenshot capture (that one
# silently hid real UI regressions; this one fronts a known org
# limitation with a loud notice).
- name: Open baseline-refresh PR
id: cpr
uses: peter-evans/create-pull-request@v6
continue-on-error: true
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: bot/visual-baselines-${{ github.run_id }}
delete-branch: true
title: "chore(visual): refresh baselines from ${{ inputs.base_url }}"
commit-message: |
chore(visual): refresh baselines from ${{ inputs.base_url }}
Reason: ${{ inputs.reason }}
Triggered by workflow_dispatch run ${{ github.run_id }} on
the axiom self-hosted runner (CT 111). Byte-equivalent
toolchain to visual-diff.yml — PNGs should match cleanly on
the follow-up Screenshot diff job.
body: |
Automated baseline refresh from the
[Seed visual baselines](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
workflow run.
- **Source URL:** `${{ inputs.base_url }}`
- **Reason:** ${{ inputs.reason }}
- **Runner:** CT 111 self-hosted axiom runner (byte-equivalent toolchain to `visual-diff.yml`).
## Review checklist
- [ ] Open the run's artifacts (or pull the PR locally and
open `tests/visual/__screenshots__/home.png`) and
visually confirm the new baseline matches an
intentional UI state.
- [ ] If matching intent: merge.
- [ ] If wrong (transient overlay, wrong URL, debug bar
visible): close the PR; investigate; re-dispatch with
a corrected URL or after fixing the source.
## Convoy context
Brief 1 of `harden-visual-diff-gate` (this workflow exists)
is now usable. Brief 2 (flip `continue-on-error: true` →
removed in `.github/workflows/visual-diff.yml`) is unblocked
once this PR merges with a fresh baseline.
labels: |
automated
visual-baselines
add-paths: |
tests/visual/__screenshots__/
# Three possible outcomes from the cpr step:
# 1. No baseline changes → cpr.outputs.pull-request-number == '' AND no bot/ branch on origin
# 2. PR opened successfully → cpr.outputs.pull-request-number != ''
# 3. Branch pushed, PR-open blocked by org setting → cpr.outputs.pull-request-number == '' BUT bot/ branch exists on origin
# The branch-exists query disambiguates outcomes 1 and 3.
- name: Disambiguate outcome
id: outcome
if: always() && steps.cpr.outputs.pull-request-number == ''
run: |
BRANCH="bot/visual-baselines-${GITHUB_RUN_ID}"
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
echo "kind=branch-pushed-pr-blocked" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
else
echo "kind=no-changes" >> "$GITHUB_OUTPUT"
fi
- name: Report — no changes
if: steps.outcome.outputs.kind == 'no-changes'
run: |
echo "::notice::No baseline changes detected. The captures from ${{ inputs.base_url }} match the committed baselines — no PR opened."
- name: Report — PR opened
if: steps.cpr.outputs.pull-request-number != ''
run: |
echo "::notice::Opened PR #${{ steps.cpr.outputs.pull-request-number }} with refreshed baselines: ${{ steps.cpr.outputs.pull-request-url }}"
- name: Report — branch pushed, manual PR-open required
if: steps.outcome.outputs.kind == 'branch-pushed-pr-blocked'
run: |
BRANCH="${{ steps.outcome.outputs.branch }}"
BASE_URL="${{ inputs.base_url }}"
REASON="${{ inputs.reason }}"
echo "::warning::Branch \`$BRANCH\` pushed with refreshed baselines, but PR-open was blocked by stwl-labs org setting (\"Allow GitHub Actions to create and approve pull requests\" is OFF)."
echo ""
echo "Operator: run this from your local checkout to open the baseline PR:"
echo ""
echo " gh pr create --base main --head $BRANCH \\\\"
echo " --title \"chore(visual): refresh baselines from $BASE_URL\" \\\\"
echo " --body \"Refresh baselines (run $GITHUB_RUN_ID). Reason: $REASON\""
echo ""
echo "See AGENTS.md § Testing § Screenshot diff for the full operator runbook."
exit 1