visual-baseline-gate
Consumes pre-classified visual-diff JSON and a reviewer-signed acceptance log to produce a single go/no-go CI verdict for visual regression. Blocks when intentional baseline changes lack a non-author reviewer sign-off or when regressions are present, and emits the binding gate artifacts - visual-gate.json + visual-gate.md - with fail-closed handling of a missing classifier run and author-cannot-self-approve enforcement, so the pipeline can exit non-zero on BLOCK. Use when the gate's input is pre-classified diff data and the enforcement concern is reviewer approval and a binding CI verdict.
Install with skills.sh (any agent)
npx skills add testland/qa --skill visual-baseline-gatevisual-baseline-gate
Overview
A typical visual-regression CI run produces an engine-specific verdict (Chromatic exit code, Percy build status, Playwright snapshot pass/fail). That's not enough for a strict gate, because:
This skill defines a build-an-X workflow that consumes both the diff classifier output and an explicit acceptance log to emit a single go/no-go verdict.
When to use
If the project uses one engine only and trusts engine-native review (e.g. all changes go through Chromatic UI approval), prefer the engine's native CI integration - see the matching engine's "CI integration" section in its SKILL.md.
How to use
Inputs
The gate consumes two inputs:
The acceptance file lives in the PR branch; merging the PR records the acceptance in git history.
The gate decision rule
def visual_gate(classifications, acceptance_log, *,
require_reviewer_acceptance=True):
accepted = {s["snapshot"] for s in acceptance_log.get("snapshots", [])}
blockers = []
for c in classifications:
if c["category"] == "regression":
blockers.append((c, "regression - blocks unconditionally"))
elif c["category"] == "intentional" and require_reviewer_acceptance:
if c["snapshot"] not in accepted:
blockers.append((c, "intentional - missing reviewer acceptance"))
elif c["category"] == "incidental":
# incidental requires investigation but does NOT block by default
pass
return {
"verdict": "no-go" if blockers else "go",
"blockers": blockers,
"incidentals": [c for c in classifications if c["category"] == "incidental"],
"intentional_accepted": [c for c in classifications
if c["category"] == "intentional"
and c["snapshot"] in accepted],
}
Default behavior:
For low-risk projects, set require_reviewer_acceptance=False so intentional changes pass without explicit acceptance - this collapses the gate to "block on regressions only."
Author cannot self-approve
For a stricter gate, validate that the commit adding .visual-acceptance.yml was authored by someone other than the PR author:
ACCEPTANCE_AUTHOR=$(git log --format='%ae' -1 .visual-acceptance.yml)
PR_AUTHOR=$(gh pr view --json author --jq '.author.login + "@..."')
if [[ "$ACCEPTANCE_AUTHOR" == "$PR_AUTHOR" ]]; then
echo "ERROR: PR author cannot self-approve baseline changes"
exit 1
fi
This is the visual-regression analog of GitHub's "require approval from someone other than the last committer" branch protection.
Emitting gate artifacts (the binding CI verdict)
Applying the decision rule in CI means turning the per-diff judgements into binding output files the pipeline acts on:
Worked example
Run the gate on one build. The classification step emitted three records, and one reviewer committed an acceptance log:
classifications = [
{"snapshot": "dashboard-mobile-375", "engine": "playwright", "category": "regression"},
{"snapshot": "pricing-tablet-768", "engine": "percy", "category": "intentional"},
{"snapshot": "hero-desktop-1280", "engine": "chromatic", "category": "intentional"},
]
acceptance_log = {"snapshots": [{"snapshot": "hero-desktop-1280",
"reason": "Intentional hero copy update per DS-789"}]}
result = visual_gate(classifications, acceptance_log)
Walking the rule per record:
| Snapshot | Category | In acceptance log? | Outcome |
|---|---|---|---|
| dashboard-mobile-375 | regression | n/a | block - regressions always block |
| pricing-tablet-768 | intentional | no | block - missing reviewer acceptance |
| hero-desktop-1280 | intentional | yes | pass - reviewer signed off |
result["verdict"] is "no-go" (two blockers), so the CI step exits non-zero and the merge is held. The gate prints:
# Visual Baseline Gate - verdict: NO-GO
- playwright :: dashboard-mobile-375 :: regression - blocks unconditionally
- percy :: pricing-tablet-768 :: intentional - missing reviewer acceptance
To turn this build green, a non-author reviewer either fixes the dashboard-mobile-375 regression or, if the diff is actually intended, adds both snapshots to .visual-acceptance.yml and re-runs the gate. The full markdown + JSON artifact and the CI wiring that surfaces this output are in references/artifact-and-ci-wiring.md.
References
Visual baseline gate - artifact format and CI wiring
View source (opens in new window)Visual baseline gate - artifact format and CI wiring
Deep reference for the visual-baseline-gate SKILL.md. Consult when emitting the gate's markdown + JSON artifact or wiring the gate into a CI pipeline.
Markdown artifact
The gate writes visual-gate.md. Its shape matches data-quality-gate for cross-domain consistency:
# Visual Baseline Gate - verdict: NO-GO
**Blockers: 2**
| Snapshot | Engine | Category | Reason | Diff |
|---------------------------|------------|-------------|--------------------------------|------|
| dashboard-mobile-375 | playwright | regression | text-truncation | [diff](playwright-report/data/dashboard-mobile-375-diff.png) |
| pricing-desktop-1280 | chromatic | intentional | missing reviewer acceptance | [build](https://chromatic.com/build/...) |
**Incidentals (advisory): 1**
| Snapshot | Engine | Category | Pattern |
|-------------------------|--------|------------|-----------------|
| onboarding-tablet-768 | percy | incidental | anti-aliasing |
**Intentional + accepted: 5**
(see .visual-acceptance.yml for rationale)
JSON sibling
A machine-readable visual-gate.json for downstream tooling:
{
"verdict": "no-go",
"blockers": [...],
"incidentals": [...],
"intentional_accepted": [...]
}
A no-go verdict exits non-zero so CI halts.
CI entrypoint script
A minimal runnable gate that reads both inputs, fails closed when no classifications were produced, and exits non-zero on no-go:
# scripts/run_visual_gate.py
import json, os, sys, yaml
from pathlib import Path
CLASS_PATH = Path("visual-classifications.json") # output of the visual-diff classification step
ACCEPT_PATH = Path(".visual-acceptance.yml")
if not CLASS_PATH.exists():
print("No visual classifications produced - fail closed.")
sys.exit(1)
classifications = json.loads(CLASS_PATH.read_text())
acceptance = yaml.safe_load(ACCEPT_PATH.read_text()) if ACCEPT_PATH.exists() else {"snapshots": []}
accepted = {s["snapshot"] for s in acceptance.get("snapshots", [])}
blockers = []
for c in classifications:
if c["category"] == "regression":
blockers.append((c, "regression"))
elif c["category"] == "intentional" and c["snapshot"] not in accepted:
blockers.append((c, "missing reviewer acceptance"))
verdict = "no-go" if blockers else "go"
print(f"# Visual Baseline Gate - verdict: {verdict.upper()}")
for c, reason in blockers:
print(f"- {c['engine']} :: {c['snapshot']} :: {reason}")
sys.exit(0 if verdict == "go" else 1)
GitHub Actions wiring
Run the gate after each engine has produced its diff manifest and after the visual-diff classification step has produced visual-classifications.json:
- name: Run visual-diff classification (advisory)
run: |
# produces visual-classifications.json
...
- name: Visual baseline gate
run: python scripts/run_visual_gate.py
- name: Upload gate artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-baseline-gate
path: |
visual-classifications.json
visual-gate.json
visual-gate.md
retention-days: 14
The if: always() upload keeps the artifact even on a no-go exit, so reviewers can audit which snapshots blocked the merge.
Related skills
chart-render-tests
Chart-render regression testing across the three chart-library families - Canvas (Chart.js: locator screenshot snapshot + `canvas.toDataURL()` diff with animations disabled), SVG (D3: `outerHTML` structural snapshot with generated-ID normalization + per-element data-binding tests), and declarative specs (Vega / Vega-Lite: JSON Schema validation + Vega-Lite → Vega compile test). Detects the family from package.json imports (chart.js / d3 / vega-lite), then applies the matching recipe; full per-library depth with citations in references/chartjs.md, references/d3.md, references/vega.md. Use when a dashboard or data product renders charts and their output needs regression coverage - before a chart-library major upgrade, after a theming change, or when runtime-generated Vega specs must be proven valid before render.
chromatic-visual-regression-testing
Authors and runs Chromatic visual tests on Storybook, Playwright, or Cypress projects via the `chromatic` CLI; configures baselines, TurboSnap, UI Review, and CI gating; reads exit codes for change-vs-error classification. Use when the project ships visual regression coverage to Chromatic Cloud.
percy-visual-regression-testing
Authors Percy visual snapshot tests via the @percy/cli + framework SDK (Playwright, Cypress, Selenium, Storybook), runs them with `percy exec -- {test command}`, configures viewports / masking / ignored regions, and reviews diffs in the Percy build UI. Use when the project ships visual regression coverage to BrowserStack Percy.
playwright-snapshots
Authors Playwright `expect(page).toHaveScreenshot()` assertions, configures masks / clips / threshold / maxDiffPixels per test, manages the per-OS / per-browser snapshot directory, and runs the update flow with `--update-snapshots`; references/ carry the responsive-breakpoint viewport matrix (one project per breakpoint, cross-breakpoint matrix report, plus Chromatic / Percy / Storybook test-runner viewport syntax). Use when the project ships self-hosted visual regression coverage in Playwright (no external snapshot service), or needs a unified multi-viewport breakpoint matrix.
storybook-visual-regression-testing
Sets up visual regression coverage for a Storybook project - either via the official @chromatic-com/storybook addon (hosted) or via @storybook/test-runner with a postVisit hook that calls Playwright's toHaveScreenshot (self-hosted). Covers test-runner install, lifecycle hooks (setup / preVisit / postVisit), and CI integration. Use when a repo already has a working `.storybook/` config and the team wants per-story visual coverage rather than page-level snapshots.
visual-baseline-conventions
Reference catalog for visual regression coverage decisions - which Storybook stories or pages get baselines, how to choose breakpoints, when to mask vs adjust threshold, when to add or remove a baseline, and a decision matrix for picking among Percy / Chromatic / Playwright / Storybook test-runner. Use when designing visual coverage for a new project or auditing an existing baseline set.