Merge Request Integration
See visual regression results directly in your GitLab merge request — no digging through job logs to find the review link.
This guide is for teams using pixmoat in their own projects. It takes about 10 minutes.
What you get
After setup, every MR that changes your UI shows:
┌─ Merge request ──────────────────────────────────────────────┐
│ │
│ ⏳ Pipeline #4021 running │
│ ✓ lint ✓ test ✗ visual-regression │
│ │
│ ✗ pixmoat/visual — 3 screenshots changed — review required │ ← commit status
│ │
│ Test summary: 3 failed, 13 passed │ ← JUnit widget
│ ✗ dashboard (1280x720) — mismatch 2.4% — review at … │
│ ✗ settings (375x812) — mismatch 0.8% — review at … │
│ │
│ View exposed artifact: Visual report │ ← HTML report
│ │
│ Environment visual/my-branch [View app] │ ← review page link
│ │
└──────────────────────────────────────────────────────────────┘
And the killer feature: when a teammate approves the changes in pixmoat, the pixmoat/visual/<project-slug> check in the MR flips to green automatically — no CI re-run needed. (The status name is per-project, so two projects on the same repo get two independent checks; see Step 3.)
The review workflow
- You push a commit that changes the UI
- The visual job uploads screenshots; pixmoat compares them against the baseline
- Changes detected → the job fails, the MR shows
pixmoat/visual/<project-slug> — failedwith a direct link - A reviewer clicks the link, sees before/after/diff, and approves (or rejects)
- On approve, the MR check turns green — merge away
CI retries are idempotent: If you retry the visual job (same commit SHA), pixmoat reuses the existing build instead of creating a duplicate — all previous runs are wiped and the build starts fresh with the new upload. After approving changes in pixmoat, a retry will re-compare against the newly-set baselines and pass automatically.
Step 1 — CI job
Add this to your .gitlab-ci.yml (assumes you already capture screenshots with @s4labs/pixmoat-playwright — see Playwright Integration if not):
visual-regression:
stage: test
image: mcr.microsoft.com/playwright:v1.52.0-noble
variables:
PIXMOAT_URL: "https://pixmoat.example.com" # your pixmoat instance
PIXMOAT_PROJECT: "my-project"
PIXMOAT_TOKEN: ${PIXMOAT_TOKEN} # masked CI/CD variable
PIXMOAT_BRANCH: ${CI_COMMIT_REF_NAME}
PIXMOAT_COMMIT: ${CI_COMMIT_SHA}
PIXMOAT_CI_URL: ${CI_JOB_URL}
PIXMOAT_PR: ${CI_MERGE_REQUEST_IID}
script:
- npm ci
- npx playwright install --with-deps chromium
- npx playwright test --reporter=@s4labs/pixmoat-playwright/reporter
artifacts:
when: always # reports must upload on failure too
reports:
dotenv: pixmoat-report/pixmoat.env # feeds environment:url below
junit: pixmoat-report/junit.xml # MR test summary widget
expose_as: "Visual report" # named link in the MR
paths:
- pixmoat-report/
expire_in: 30 days
environment:
name: visual/$CI_COMMIT_REF_SLUG
url: $PIXMOAT_REVIEW_URL # exported by the dotenv report
Optional: keep the “View app” link when the job fails
GitLab treats environment: as a deployment. If your visual job fails on blocked builds (no allow_failure: true), the MR shows “Failed to deploy to visual/…” instead of a button — exactly when you most want the link. Fix: move environment: off the visual job into a tiny companion job that always succeeds:
visual-review-link:
stage: test
image: alpine:latest
needs:
- job: visual-regression
artifacts: true # inherits PIXMOAT_REVIEW_URL from the dotenv report
script:
- echo "pixmoat review → $PIXMOAT_REVIEW_URL"
environment:
name: visual/$CI_COMMIT_REF_SLUG
url: $PIXMOAT_REVIEW_URL
allow_failure: true
rules:
- when: always # run even when visual-regression fails
(And remove the environment: block from the visual job itself.)
The reporter writes pixmoat-report/ automatically (override the location with PIXMOAT_REPORT_DIR). It contains:
| File | Feeds | You see |
|---|---|---|
pixmoat.env | artifacts:reports:dotenv + environment:url | “View app” link to the review page |
junit.xml | artifacts:reports:junit | Per-screenshot pass/fail in the test summary widget |
report.html | expose_as | Self-contained summary, downloadable from the MR |
The paths above are relative to the directory you run
playwright testfrom. If your job doescd e2e && npx playwright test, the artifact paths becomee2e/pixmoat-report/....
Step 2 — Token
In your GitLab project: Settings > CI/CD > Variables → add PIXMOAT_TOKEN (masked) with the project access token from pixmoat (Project Settings > Tokens in the pixmoat UI).
Use GitLab’s masked variable/secret injection for every token reference. Never put a token in .pixmoat.yaml, source control, job logs, or build artifacts. The examples in this guide intentionally reference ${PIXMOAT_TOKEN} rather than a token value.
Push a commit — you should already see the test summary widget, the exposed report, and the environment link in the MR.
Step 3 — Commit status (recommended)
This is the part that makes review feel native. Configure it once in the pixmoat UI, not in CI:
- Open your project in pixmoat → Settings > Git Provider
- Select your git host (GitLab, Bitbucket Cloud, Bitbucket Server, or Generic)
- Fill in the provider-specific fields (base URL, repository, token)
- Save. Done — no CI changes needed.
For a provider-specific setup, configure the provider in the project settings:
- GitLab: fill in GitLab URL, project path, and a token with
apiscope (project access token recommended) - Bitbucket or another Git provider: choose the matching provider and fill in its repository and token details
From now on pixmoat posts a pixmoat/visual/<project-slug> status on every commit it builds. The status name is scoped to the project’s slug so that multiple pixmoat projects pointing at the same repository (e.g. one for the app and one for the marketing site) each post a distinct check on the same commit instead of overwriting a single shared status. Each check links to its own project’s review page:
| When | Status shown in MR |
|---|---|
| Screenshots uploading | pixmoat/visual/<slug> — running |
| No changes / new baselines | pixmoat/visual/<slug> — passed ✓ |
| Unreviewed changes | pixmoat/visual/<slug> — failed ✗ (click → review page) |
| Reviewer approves in pixmoat | flips to ✓ without re-running CI |
| Reviewer rejects in pixmoat | stays ✗ |
Step 4 — Block merges until review (optional)
Two independent gates, use either or both:
- Job-based: remove
allow_failure: truefrom the visual job — a blocked build fails the pipeline. Approving in pixmoat then requires a job retry to turn the pipeline green. - Status-based: in GitLab, Settings > Merge requests > Status checks / Merge checks, require
pixmoat/visual/<project-slug>(use the exact per-project name pixmoat posts). Approving in pixmoat flips this green instantly, no retry needed. This is the smoother workflow.
Blocking merges on GitLab Free
GitLab Free/Premium does not support external status checks as merge gates. The only merge-blocking mechanism is “Pipelines must succeed” (Settings > Merge requests). This means the review state must live inside the pipeline as a hard-failing job.
Why allow_failure: true is wrong
If you mark the visual-review job with allow_failure: true, GitLab treats the pipeline as green even when the job fails — the merge is unblocked and the review is bypassed entirely. The job must fail hard to block the merge.
The dedicated visual-review job
Add a separate job that runs pixmoat check --wait after your capture job uploads screenshots:
visual-regression:
stage: test
script:
- npm ci
- npx playwright install --with-deps chromium
- npx playwright test --reporter=@s4labs/pixmoat-playwright/reporter
artifacts:
when: always
reports:
dotenv: pixmoat-report/pixmoat.env
junit: pixmoat-report/junit.xml
paths:
- pixmoat-report/
rules:
- changes:
- "frontend/**"
- "*.css"
- "*.html"
- "playwright/**"
visual-review:
stage: test
needs: [visual-regression]
image: ghcr.io/s4labs/pixmoat-cli:latest
variables:
PIXMOAT_URL: "https://pixmoat.example.com"
PIXMOAT_PROJECT: "my-project"
PIXMOAT_TOKEN: ${PIXMOAT_TOKEN}
PIXMOAT_BRANCH: ${CI_COMMIT_REF_NAME}
PIXMOAT_COMMIT: ${CI_COMMIT_SHA}
script:
- pixmoat check --wait
rules:
- changes:
- "frontend/**"
- "*.css"
- "*.html"
- "playwright/**"
pixmoat check --wait polls the build status and exits with:
- 0 — approved or no changes detected
- 1 — rejected (review comments in stdout)
- 2 — still in review (timeout reached, default 30 min)
- 3 — build not found or error
While review is pending, the job stays red → the pipeline stays red → the merge is blocked.
Skipping backend-only pipelines with rules: changes:
Use GitLab’s rules: changes: to skip both the capture and review jobs when no UI-relevant files changed. This avoids wasting CI minutes and blocking backend-only MRs on a review that will never arrive.
Adjust the path patterns to match your project’s frontend directory structure:
rules:
- changes:
- "frontend/**"
- "src/components/**"
- "*.css"
- "playwright/**"
When both jobs are skipped, the pipeline contains only your other (passing) jobs and merging proceeds normally.
The approve → retry → green loop
Once a reviewer approves the screenshots in pixmoat:
- Reviewer approves in the pixmoat UI
- Retry the
visual-reviewjob in GitLab (manually, or via an orchestrator like hive) - The retried job runs
pixmoat check --waitagain, finds the build is now approved, exits 0 - Pipeline turns green → merge is unblocked
This loop is idempotent: retrying the job multiple times always re-checks the current pixmoat build status.
Tip: If you use an AI orchestrator (e.g. hive), it can automate step 2 — retrying the job via the GitLab API after detecting approval. See the AI Agent Guide for the orchestrator integration contract.
Required Approvals (review policy)
Projects can require multiple approvers and/or approval from a specific role before a review can be finished. When a review policy is active, the pixmoat/visual commit status stays red until the policy conditions are met — not just until all screenshots are decided.
Configuring the policy
Set the policy in the pixmoat project settings under Review Policy, or in .pixmoat.yaml:
review_policy:
min_approvals: 2
required_role: project_admin # null | project_admin | org_admin
require_distinct_from_author: true
applies_to_branches: ["main", "release/*"] # glob; empty = all branches
agent_may_finish: true
| Field | Default | Description |
|---|---|---|
min_approvals | 1 | Minimum number of distinct human approvers. Agent approvals do not count. |
required_role | null | At least one approver must hold this project/org role. |
require_distinct_from_author | false | The build author cannot be the sole approver — a second pair of eyes is required. |
applies_to_branches | [] (all) | Glob patterns for branches the policy applies to. Empty means all branches. |
agent_may_finish | true | When false, agents cannot call finish-review on policy-gated sessions. |
require_codeowner_approval | false | When true, each snapshot with resolved CODEOWNERS-assigned reviewers must have at least one approval from an owner (or org_admin override). See CODEOWNERS Routing below. |
Agent completion modes
An agent always stages its per-run decisions first. It must inspect every actionable run, leave meaningful approval/rejection comments, decide all of them, and then call finish_review (or pixmoat finish --build <id>) exactly once. A rejection is a terminal review result, not an API failure.
| Mode | Settings | What happens when the agent finishes |
|---|---|---|
| Manual | agent_auto_approve: none | Agent feedback is advisory; Pixmoat returns human_action_required. |
| Hybrid | intent_only, agent_may_finish: false, or unmet human quorum/CODEOWNERS | The agent can triage and comment, but Pixmoat waits for human action. |
| Fully automatic | agent_auto_approve: all, agent_may_finish: true, and no unmet review-policy requirement | Pixmoat can complete the staged review after the explicit finish request. |
pixmoat finish exits 0 for approved, 3 for terminal rejected, 2 while processing, 1 for pending decisions or a human/policy block, and 4 for auth/configuration/server errors. MCP returns the same domain statuses (approved, rejected, waiting, pending_decisions, human_action_required, or agent_finish_not_allowed); only transport failures are MCP errors.
How it interacts with the merge gate
- Status-based gate: The
pixmoat/visualcommit status remainsfaileduntil all runs are decided AND the review policy is satisfied. Approving in pixmoat with the required quorum flips the status topassedimmediately. - Job-based gate:
pixmoat check --waitexits1when the policy is unmet. After the policy is satisfied, retrying the job exits0. - No policy (default): The default policy (
{}/min_approvals: 1) behaves exactly like today — a single human approval and all runs decided is enough.
CODEOWNERS routing
When the Playwright capture client uploads the repository’s CODEOWNERS file (see Playwright Integration — CODEOWNERS), pixmoat parses it and assigns each changed snapshot to its code owners based on snapshot→source-file correlation.
The review UI shows:
- Owner chips on each run in the run list
- “Assigned to you” filter to show only runs requesting the current user’s review
- “Awaiting review from…” header listing owners who haven’t yet approved
To resolve CODEOWNERS handles (@team-checkout) to pixmoat users, configure the Owner Map in project settings or .pixmoat.yaml:
owner_map:
"@team-checkout": ["alice@example.com", "bob@example.com"]
"@design": ["carol@example.com"]
default_reviewers: ["dave@example.com"]
default_reviewers is used when no CODEOWNERS rule matches a snapshot’s correlated source files.
When require_codeowner_approval: true is set in the review policy, each run with resolved owners must have at least one approval from an owner before finish-review is allowed. An org_admin can override this requirement.
Step 5 — Inbound merge webhook (baseline promotion on merge)
By default, approving screenshots on a feature branch only updates the baseline for that branch. To automatically promote approved baselines to the target branch (e.g., main) when the MR/PR is merged, configure a merge webhook from your git host.
Without this webhook, target-branch baselines only advance when CI runs directly on that branch. This is safe — it just means already-approved screenshots may show as “changed” again on the next target-branch build until they’re re-approved or the target-branch CI catches up.
How it works
- A merge request / pull request is merged in your git host
- The git host sends a webhook to pixmoat with the merge details
- pixmoat finds the approved build for the merged branch
- For each approved screenshot, pixmoat checks whether the target branch baseline is still what the reviewer saw when approving (the “up-to-date check”)
- If up-to-date → the approved image is promoted to the target branch baseline
- If the target branch baseline was changed since the review (e.g., another MR merged first) → the screenshot is skipped to prevent silently overwriting newer work
Configure the webhook secret in pixmoat
- Open your project in pixmoat → Settings > Git Provider
- Set the Webhook Secret field to a random string (e.g.,
openssl rand -hex 32) - Save
This secret authenticates inbound webhooks. GitLab sends it in the X-Gitlab-Token header; Bitbucket and generic providers use HMAC-SHA256 signatures.
Configure the webhook in your git host
GitLab
- In your GitLab project: Settings > Webhooks > Add new webhook
- Fill in:
- URL:
https://pixmoat.example.com/api/projects/{slug}/webhooks/gitlab - Secret token: the same random string you configured in pixmoat
- Trigger: check only Merge request events
- URL:
- Click Add webhook
Verify it works
- Merge any MR that has an approved pixmoat build
- In pixmoat, the target branch baselines should update immediately (no CI re-run needed)
- Check the webhook delivery log in GitLab (Settings > Webhooks > Edit > Recent events) — you should see a
200response with"status": "processed"
Conflict detection
If two MRs change the same screenshot and both get approved, the first one to merge promotes normally. The second MR’s merge webhook detects that the target baseline has changed since the reviewer approved, and skips that screenshot instead of silently overwriting the first MR’s change.
In this case the webhook returns a partial or conflict status. The affected screenshots will appear as “changed” in the next target-branch CI build, prompting a fresh review.
Dismiss a stale branch request
If a branch was deleted or the work was abandoned, a project admin can use Dismiss on its row in the review queue. After confirmation, the current reviewable build is marked superseded and removed from the active queue. Its screenshots and build history are retained, and no baseline is approved or changed.
Webhook redelivery
The endpoint is idempotent. If GitLab redelivers the same webhook (same merge SHA + target branch), pixmoat returns 200 with "status": "already_processed" without re-running promotion.
Troubleshooting
| Symptom | Likely cause |
|---|---|
No widgets in MR, job log shows no matching files for pixmoat-report/ | Artifact paths don’t match where the reporter wrote them — check the job’s working directory (see the note in Step 1) |
| “Failed to deploy to visual/…” instead of a “View app” button | The visual job failed (blocked build) and declares environment: itself — move it to a companion job (see “Optional” above) |
No pixmoat/visual status appears | Git provider not configured/enabled in pixmoat project settings, token lacks required scope, or the repository path/ID doesn’t match |
| Status link points to the wrong host | Ask the instance administrator to verify the public app URL |
Webhook returns 404 | The project slug in the webhook URL doesn’t match a pixmoat project, or the provider kind in the URL doesn’t match the project’s configured provider |
Webhook returns 401 | The webhook secret doesn’t match between the git host and pixmoat project settings |
| Baselines not promoted after merge | Check that the build was approved in pixmoat before merging. Unapproved or rejected builds have no approved runs to promote. |
Webhook returns 200 with "status": "ignored" | The webhook fired for a non-merge MR event (open, close, reopen, update). This is normal — only merge events trigger promotion. Ensure Merge request events is the only trigger checked. |