visual-baseline-gate
Consumes visual-diff-classifier 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 a markdown + JSON artifact for the CI step. Use this skill when the gate's input is pre-classified diff data and the enforcement concern is reviewer approval, not when the goal is fanning out to multiple engines (use visual-ci-gate-orchestrator for that).
visual-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.
Step 1 - Define the input shape
The gate consumes two inputs:
The acceptance file lives in the PR branch; merging the PR records the acceptance in git history.
Step 2 - Define 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."
Step 3 - Enforce 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.
Step 4 - Emit the artifact
Markdown summary (matches the data-quality-gate shape 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)
Plus a JSON sibling for downstream tooling:
{
"verdict": "no-go",
"blockers": [...],
"incidentals": [...],
"intentional_accepted": [...]
}
A no-go verdict exits non-zero so CI halts.
Worked example: minimal Python implementation
# scripts/run_visual_gate.py
import json, os, sys, yaml
from pathlib import Path
CLASS_PATH = Path("visual-classifications.json") # output of visual-diff-classifier
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)
CI wiring (after each engine has produced its diff manifest, and after the visual-diff-classifier has produced visual-classifications.json):
- name: Run visual-diff-classifier (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