stableFor developers

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

  1. You push a commit that changes the UI
  2. The visual job uploads screenshots; pixmoat compares them against the baseline
  3. Changes detected → the job fails, the MR shows pixmoat/visual/<project-slug> — failed with a direct link
  4. A reviewer clicks the link, sees before/after/diff, and approves (or rejects)
  5. 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

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:

FileFeedsYou see
pixmoat.envartifacts:reports:dotenv + environment:url“View app” link to the review page
junit.xmlartifacts:reports:junitPer-screenshot pass/fail in the test summary widget
report.htmlexpose_asSelf-contained summary, downloadable from the MR

The paths above are relative to the directory you run playwright test from. If your job does cd e2e && npx playwright test, the artifact paths become e2e/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.

This is the part that makes review feel native. Configure it once in the pixmoat UI, not in CI:

  1. Open your project in pixmoat → Settings > Git Provider
  2. Select your git host (GitLab, Bitbucket Cloud, Bitbucket Server, or Generic)
  3. Fill in the provider-specific fields (base URL, repository, token)
  4. 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 api scope (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:

WhenStatus shown in MR
Screenshots uploadingpixmoat/visual/<slug> — running
No changes / new baselinespixmoat/visual/<slug> — passed
Unreviewed changespixmoat/visual/<slug> — failed ✗ (click → review page)
Reviewer approves in pixmoatflips to ✓ without re-running CI
Reviewer rejects in pixmoatstays ✗

Step 4 — Block merges until review (optional)

Two independent gates, use either or both:

  • Job-based: remove allow_failure: true from 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:

  1. Reviewer approves in the pixmoat UI
  2. Retry the visual-review job in GitLab (manually, or via an orchestrator like hive)
  3. The retried job runs pixmoat check --wait again, finds the build is now approved, exits 0
  4. 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
FieldDefaultDescription
min_approvals1Minimum number of distinct human approvers. Agent approvals do not count.
required_rolenullAt least one approver must hold this project/org role.
require_distinct_from_authorfalseThe 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_finishtrueWhen false, agents cannot call finish-review on policy-gated sessions.
require_codeowner_approvalfalseWhen 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.

ModeSettingsWhat happens when the agent finishes
Manualagent_auto_approve: noneAgent feedback is advisory; Pixmoat returns human_action_required.
Hybridintent_only, agent_may_finish: false, or unmet human quorum/CODEOWNERSThe agent can triage and comment, but Pixmoat waits for human action.
Fully automaticagent_auto_approve: all, agent_may_finish: true, and no unmet review-policy requirementPixmoat 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/visual commit status remains failed until all runs are decided AND the review policy is satisfied. Approving in pixmoat with the required quorum flips the status to passed immediately.
  • Job-based gate: pixmoat check --wait exits 1 when the policy is unmet. After the policy is satisfied, retrying the job exits 0.
  • 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

  1. A merge request / pull request is merged in your git host
  2. The git host sends a webhook to pixmoat with the merge details
  3. pixmoat finds the approved build for the merged branch
  4. For each approved screenshot, pixmoat checks whether the target branch baseline is still what the reviewer saw when approving (the “up-to-date check”)
  5. If up-to-date → the approved image is promoted to the target branch baseline
  6. 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

  1. Open your project in pixmoat → Settings > Git Provider
  2. Set the Webhook Secret field to a random string (e.g., openssl rand -hex 32)
  3. 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

  1. In your GitLab project: Settings > Webhooks > Add new webhook
  2. 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
  3. Click Add webhook

Verify it works

  1. Merge any MR that has an approved pixmoat build
  2. In pixmoat, the target branch baselines should update immediately (no CI re-run needed)
  3. Check the webhook delivery log in GitLab (Settings > Webhooks > Edit > Recent events) — you should see a 200 response 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

SymptomLikely 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” buttonThe visual job failed (blocked build) and declares environment: itself — move it to a companion job (see “Optional” above)
No pixmoat/visual status appearsGit 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 hostAsk the instance administrator to verify the public app URL
Webhook returns 404The 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 401The webhook secret doesn’t match between the git host and pixmoat project settings
Baselines not promoted after mergeCheck 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.