Use when visual diffs are already classified and CI needs one binding go/no-go: blocks on regressions or on intended baseline changes without sign-off from a reviewer who is not the author, fails closed when the classifier did not run, and writes visual-gate.json and .md.
91%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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.
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)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.
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)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: 14The if: always() upload keeps the artifact even on a no-go exit, so
reviewers can audit which snapshots blocked the merge.