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>
218 lines
9.6 KiB
YAML
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
|