GitLab CI
This guide adds pixmoat to a GitLab project. When you’re done:
- every merge request pipeline uploads screenshots to pixmoat;
- a
visual-reviewjob fails until the changes are reviewed, so Pipelines must succeed blocks the merge; - optionally, pixmoat posts a
pixmoat/visual/<project-slug>commit status on the merge request pipeline, and promotes approved baselines when the merge request is merged.
It assumes you have a pixmoat project and a project access token, and that your Playwright tests capture screenshots with the pixmoat fixture. If not, start with Your first build, which walks through the same pipeline from an empty project.
Add the pipeline
Save this as .gitlab-ci.yml at the repository root, or copy its two jobs, workflow: and variables: into your existing file. Set PIXMOAT_PROJECT to your project’s slug, and keep the Playwright image tag equal to the @playwright/test version in your package.json.
# pixmoat visual regression in GitLab CI: a capture job and a merge gate.
#
# File location: .gitlab-ci.yml at the repository root. If you already have
# one, copy the two jobs and the variables into it.
# Install: npm install --save-dev @playwright/test @s4labs/pixmoat-playwright,
# plus playwright.config.ts, global-setup.ts and
# global-teardown.ts from the Playwright example.
# Variables: PIXMOAT_TOKEN, the project access token from pixmoat (the
# project's Settings > Tokens tab), as a masked but not
# protected CI/CD variable (Settings > CI/CD > Variables), so
# merge request pipelines on unprotected branches get it. Set
# PIXMOAT_PROJECT below to your project slug.
# Command: push a branch and open a merge request.
# Expected: visual-regression uploads the screenshots and logs
# "[pixmoat] Build finished: ...". visual-review prints
# "pixmoat verdict: PASS" and passes, or prints REVIEW,
# REVIEW_NEW or FAIL and fails until the changes are approved in
# pixmoat and the job is retried.
#
# To block merges, turn on "Pipelines must succeed" under
# Settings > Merge requests. Don't add allow_failure to visual-review: a job
# that may fail leaves the pipeline green and the review is bypassed.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
PIXMOAT_URL: "https://app.pixmoat.com"
PIXMOAT_PROJECT: "my-project"
stages:
- test
# Captures and uploads screenshots with the fixture. finishBuild() never fails
# the job on visual changes; visual-review does that.
visual-regression:
stage: test
# Keep the image tag equal to the @playwright/test version in package.json.
image: mcr.microsoft.com/playwright:v1.52.0-noble
variables:
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 test
artifacts:
when: always
reports:
# global-setup.ts writes PIXMOAT_BUILD_ID=<id> here. The path is relative
# to the directory the job runs `npx playwright test` from.
dotenv: pixmoat-build.env
# Reads the build verdict and fails unless it is PASS. wait=true waits for the
# server to finish processing (up to 300 s), not for a human review.
visual-review:
stage: test
image: alpine:3.20
needs:
- job: visual-regression
artifacts: true # brings PIXMOAT_BUILD_ID from the dotenv report
script:
- apk add --no-cache curl jq
- |
verdict=$(curl --fail --silent --show-error --max-time 330 \
-H "Authorization: Bearer $PIXMOAT_TOKEN" \
"$PIXMOAT_URL/api/builds/$PIXMOAT_BUILD_ID/agent-summary?wait=true&timeout=300" \
| jq -r '.verdict')
echo "pixmoat verdict: $verdict"
test "$verdict" = "PASS"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
The file needs playwright.config.ts, global-setup.ts and global-teardown.ts from Connect Playwright, and the PIXMOAT_TOKEN variable from the next section.
It has two jobs:
| Job | Runs on | What it does |
|---|---|---|
visual-regression | Merge request pipelines and the default branch | Runs npx playwright test, which uploads the screenshots into one build and finishes it. It doesn’t fail on visual changes. |
visual-review | Merge request pipelines | Reads the build’s verdict and fails unless it’s PASS. This is the job that blocks the merge. |
The default branch needs its own builds: merge request builds compare their screenshots with the default branch’s baselines. See Baselines and branches.
PIXMOAT_BRANCH is set from $CI_COMMIT_REF_NAME, which is the merge request’s source branch in a merge request pipeline. PIXMOAT_PR is empty on the default branch, and that’s fine.
Build ID handoff
The gate job needs the ID of the build the capture job created. The example passes it like this:
global-setup.tscallsstartBuild()and writesPIXMOAT_BUILD_ID=<id>topixmoat-build.env.visual-regressionpublishes that file as adotenvreport.visual-reviewlistsvisual-regressionunderneeds:withartifacts: true, so GitLab setsPIXMOAT_BUILD_IDin its environment.
The report path is relative to the directory the job runs npx playwright test from. If your job runs cd tests && npx playwright test, change the path to tests/pixmoat-build.env. Otherwise the gate job starts without PIXMOAT_BUILD_ID, and curl fails with an HTTP 404.
Don’t set PIXMOAT_BUILD_ID on the capture job yourself. When it’s already set, startBuild() uploads into that build instead of creating a new one.
Who finishes the build
The capture job owns the build from start to finish. global-teardown.ts calls finishBuild() once, after every Playwright worker in that job has uploaded. That makes the build capture complete. The gate job only reads the verdict. It never starts or finishes a build.
pixmoat doesn’t combine screenshots or counts from several jobs into one build. Keep the capture in one job, and let Playwright workers run in parallel inside it. Don’t split it with parallel: or --shard:
- If each shard creates its own build and finishes it, each build is missing the other shards’ snapshots, so they show as Removed. See Completeness and removals.
- If the shards share one build ID, the first shard to call
finishBuild()marks the build capture complete. The snapshots the other shards haven’t uploaded yet show as Removed.
Retrying the capture job
pixmoat keeps one build per branch and commit. When a capture for a branch and commit starts after that commit’s build has finished, for example because you retried visual-regression or ran a new pipeline, pixmoat reopens the build. It discards the build’s screenshots and any review in progress, then compares the new upload again. If the earlier build is still running, for example in a second pipeline, pixmoat creates a separate build instead of sharing it.
After a review, retry visual-review, not the capture job. See The approve, retry, green loop.
Store the token
In GitLab, open Settings > CI/CD > Variables and select Add variable:
- Key:
PIXMOAT_TOKEN - Value: the project access token from pixmoat (Settings, then the Tokens tab). It starts with
pxg_. - Visibility: Masked, so the value is hidden in job logs.
- Protect variable: leave it unchecked. GitLab passes protected variables only to pipelines on protected branches and tags. Merge requests usually come from unprotected branches, so their pipelines wouldn’t get the token.
Without the token, visual-regression fails at the start of the test run with:
Missing required pixmoat environment variables: PIXMOAT_TOKEN
The same happens for merge requests from forks: their pipelines run in the fork and don’t get your project’s variables.
Never put the token in .gitlab-ci.yml, .pixmoat.yaml, job logs or artifacts. The example only references $PIXMOAT_TOKEN.
The gate job
visual-review reads the verdict from GET /api/builds/{id}/agent-summary and passes only when it’s PASS:
verdict | Meaning | Job |
|---|---|---|
PASS | No pending changes, no rejections | Passes |
REVIEW | Changed screenshots waiting for review | Fails |
REVIEW_NEW | Only new screenshots waiting for review | Fails |
FAIL | A screenshot was rejected, the build failed, a baseline size mismatch, or stale target baselines | Fails |
wait=true waits for pixmoat to finish processing the build, up to timeout seconds (default 120, maximum 300). It doesn’t wait for a human review, so a job with changes to review fails as soon as processing is done. If processing takes longer than the timeout, pixmoat returns HTTP 408 and curl --fail fails the job. Retry the job.
If the default branch’s baselines changed after capture, the gate fails even when the branch review still shows Approved. Update the branch and run a new capture before retrying the gate. See When the target baselines change. A previously green job is not revoked automatically, so these checks alone do not enforce freshness at merge time.
The gate fails on pending changes whatever the project’s blocking mode is. If your project is in report-only mode and changes shouldn’t fail CI, leave visual-review out.
The verdict counts approve and reject decisions but doesn’t check the approval policy. If merges must wait for the policy, also require the commit status.
Capture the proposed merge
GitLab merged-result pipelines can use the beta integration capture identity path. Configure an enabled GitLab connection with a token that can read the merge request, repository branches, and commits. Enable merged-result pipelines in GitLab, run Playwright on that pipeline’s checkout, and run a complete capture on the target branch for its exact commit. Approve any changes in the target build first.
In a merged-result job, the Playwright client reads GitLab’s source and target branch SHAs, target branch name, MR number, pipeline ID, and CI_COMMIT_SHA. It sends the source SHA as the build commit and the checked-out merged-result SHA as the captured commit. It checks git rev-parse HEAD before upload. The diff-base SHA is not a substitute for target HEAD.
Keep the existing capture command and visual-review gate. startBuild() creates a fresh integration build even if a build ID was supplied. It then shares that new ID with workers. The reporter also creates a fresh integration build. Its run fails on blocked or failed validation, upload errors, finish errors, or no screenshots, even if blocking enforcement was disabled. Fixture teardown records the result without failing the capture job; require the separate visual-review gate. After approving a current integration build, retry that gate with the same build ID. Start a new capture when the candidate becomes stale or unverifiable. Ordinary branch jobs keep their existing behavior.
A stale result means the source or target changed. Capture a new proposed merge. target_baseline_pending means the exact target build has not been accepted or its saved capture was replaced. Finish that target build and start a new integration capture. Provider failures cannot grant a pass. This path currently supports visual baselines only; enabled blocking performance, accessibility, or console checks return unsupported_blocking_checks.
Merge-train pipelines are rejected because their candidates may contain other queued merge requests. Detached MR jobs remain ordinary branch captures. Automatic revocation uses the configured status queue. For repository enforcement, use Require current merge candidates. A green integration job alone does not close the race between validation and merge.
GitLab describes the metadata in predefined CI variables and the candidate workflow in merged-result pipelines.
GitLab Free merge queue
GitLab Free can use the beta Pixmoat merge queue with fast-forward merges. The queue serializes final merges while developers continue working on separate branches. It rebases an outdated source branch onto the current target, requires a fresh complete capture and review, and merges only the exact approved source commit. Fast-forward-only repository settings make GitLab reject a merge if the target moves outside that candidate after the queue’s last check. This mode does not require Premium or Ultimate merged-result pipelines.
The coordinator runs from the protected default branch. It processes the oldest open MR with the pixmoat-merge-queue label, one MR per project at a time. Remove the label to cancel or release a blocked request. A draft, conflict, failed test, pending review, provider failure or missing artifact keeps the request waiting. The next scheduled pass reconciles GitLab state after a restart or uncertain API response. A queued request never grants screenshot approval.
Set the Git provider’s merge_validation to gitlab_free_queue and provide merge_queue_visual_jobs, a list of required capture job names. The server verifies the MR head pipeline, exact source checkout, current target HEAD, source ancestry, fast-forward settings, no squash, required successful pipelines and protected target permissions. Capture identity still uses kind: integration; the captured commit equals the source commit in this mode. Comparisons use the saved target baselines. An accepted complete capture of the exact target commit is required.
Set PIXMOAT_GITLAB_FREE_QUEUE=1 in capture jobs and source ci/merge-queue/prepare-capture.sh before Playwright. It fetches target HEAD and exports PIXMOAT_QUEUE_TARGET_SHA. The client checks both Git HEAD and target ancestry. A complete capture job can succeed while screenshot review is pending; the coordinator separately requires a finished current Pixmoat review and a successful full pipeline. Upload errors and incomplete captures still fail the capture job. Capture jobs and their build dependencies must run on every MR and default-branch commit in queue mode.
The repository must disable direct pushes, squash and automatic rebase at merge time, use fast-forward merges, and restrict merge permission to Maintainers. Use a Developer credential for Hive and store the coordinator’s Maintainer PAT as a protected masked variable. GitLab Free permits Maintainers to bypass the queue and change these settings. When the coordinator uses an operator’s account, that operator retains the same bypass capability. This is a controlled workflow, not an administrator-proof GitLab merge rule.
The queue reuses the visual integration-capture limitations. Forks, merge trains and enabled blocking nonvisual checks cannot grant a verified capture. Live rollout requires the server and client versions that support this policy, repository protection, a trusted coordinator and a current accepted target capture. Keep the schedule inactive until those prerequisites have been verified.
Require current merge candidates
The gitlab_up_to_date policy is an opt-in beta available only for GitLab Premium or Ultimate projects with merged-result pipelines. GitLab Free does not support this solution. Free projects can use the ordinary branch review, CI verdict gate, and obsolete-status revocation described elsewhere in this guide; those features do not provide this merge-time policy. It verifies observable repository rules and blocks ordinary branch captures from passing the merge gate, including in report-only mode. Live merge validation on a configured GitLab project is still required before production rollout. These repository rules address code freshness; baseline or comparison-policy changes at the same target commit still rely on eligibility checks and asynchronous status revocation.
Configure these GitLab settings before enabling it:
- Select Merge commit with semi-linear history and disable Automatic rebase before merge. GitLab requires an up-to-date source branch with this method. Automatic rebase can change the merge result without rerunning CI.
- Enable Pipelines must succeed and Enable merged results pipelines. Disable skipped-pipeline merges and merge trains. Set Squash commits when merging to Do not allow.
- Protect the target branch. Set Allowed to push and merge to No one and disable force pushes. Every matching wildcard or inherited rule must also disallow direct pushes. Allow authorized reviewers to merge through merge requests.
- Keep
visual-regressionandvisual-reviewas automatic jobs withallow_failure: falsein every MR pipeline. Protect the CI configuration from changes that remove or bypass these jobs. This policy verifies their presence and failure behavior, not their script contents.
GitLab documents semi-linear merges and automatic rebase and merged-result pipelines. Repository administrators must retain these settings. Pixmoat checks them during capture verification and later eligibility checks; it does not prevent administrators from changing GitLab rules.
After configuring the GitLab connection in pixmoat, update it through PUT /api/projects/{slug}/git-provider using project administrator credentials. Fragment, with your existing base URL and project path:
{
"kind": "gitlab",
"enabled": true,
"base_url": "https://gitlab.com",
"repo": "group/project",
"merge_validation": "gitlab_up_to_date"
}
Omitting merge_validation in later settings updates preserves the policy. Set it explicitly to branch_review to return to ordinary review. Disabling or changing the provider requires that explicit change first.
Install the fixture and setup/teardown from Your first build. Save this pipeline at .gitlab-ci.yml, set PIXMOAT_PROJECT, and store PIXMOAT_TOKEN as described above. Keep the Playwright image version aligned with your installed Playwright version. Use the fixture rather than the reporter for this two-job review loop:
# Opt-in semi-linear enforcement. Use the fixture and global setup/teardown
# from the Playwright example. Copy verify-merge-capture.sh to ci/ in your repo.
# GitLab Premium or Ultimate merged-result pipelines are required.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
variables:
PIXMOAT_URL: "https://app.pixmoat.com"
PIXMOAT_PROJECT: "my-project"
GIT_DEPTH: "0"
stages:
- test
visual-regression:
stage: test
image: mcr.microsoft.com/playwright:v1.52.0-noble
allow_failure: false
variables:
PIXMOAT_BRANCH: $CI_COMMIT_REF_NAME
PIXMOAT_COMMIT: $CI_COMMIT_SHA
PIXMOAT_CI_URL: $CI_JOB_URL
PIXMOAT_PR: $CI_MERGE_REQUEST_IID
script:
- |
if [ "$CI_PIPELINE_SOURCE" = "merge_request_event" ]; then
test "$CI_MERGE_REQUEST_EVENT_TYPE" = "merged_result"
git merge-base --is-ancestor "$CI_MERGE_REQUEST_TARGET_BRANCH_SHA" "$CI_MERGE_REQUEST_SOURCE_BRANCH_SHA"
fi
- npm ci
- npx playwright test
artifacts:
when: always
reports:
dotenv: pixmoat-build.env
visual-review:
stage: test
image: alpine:3.20
allow_failure: false
needs:
- job: visual-regression
artifacts: true
script:
- apk add --no-cache curl jq
- sh ci/verify-merge-capture.sh
rules:
# Keep the job in detached MR pipelines too. The script rejects them.
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Copy this gate to ci/verify-merge-capture.sh:
#!/bin/sh
# Copy to ci/verify-merge-capture.sh. Retry this gate after review; do not recapture.
set -eu
: "${PIXMOAT_URL:?}" "${PIXMOAT_TOKEN:?}" "${PIXMOAT_BUILD_ID:?}"
: "${CI_PIPELINE_ID:?}" "${CI_MERGE_REQUEST_IID:?}" "${CI_COMMIT_SHA:?}"
: "${CI_MERGE_REQUEST_SOURCE_BRANCH_SHA:?}" "${CI_MERGE_REQUEST_TARGET_BRANCH_SHA:?}"
: "${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:?}"
test "${CI_PIPELINE_SOURCE:-}" = "merge_request_event"
test "${CI_MERGE_REQUEST_EVENT_TYPE:-}" = "merged_result"
printf '%s' "$PIXMOAT_BUILD_ID" | jq -Rse 'test("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")' >/dev/null
response=$(curl --fail --silent --show-error --max-time 60 \
-H "Authorization: Bearer $PIXMOAT_TOKEN" \
"${PIXMOAT_URL%/}/api/builds/$PIXMOAT_BUILD_ID")
printf '%s\n' "$response" | jq -se --argjson pipeline "$CI_PIPELINE_ID" --argjson mr "$CI_MERGE_REQUEST_IID" \
--arg source "$CI_MERGE_REQUEST_SOURCE_BRANCH_SHA" \
--arg target "$CI_MERGE_REQUEST_TARGET_BRANCH_SHA" \
--arg captured "$CI_COMMIT_SHA" --arg branch "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \
--arg build "$PIXMOAT_BUILD_ID" '
length == 1 and (.[0] | .merge_validation == "gitlab_up_to_date" and .ci_status == "passed"
and .build.id == $build and .build.pr_number == $mr
and .baseline_freshness.state == "current"
and .capture_identity.kind == "integration"
and .capture_identity.verification_state == "verified"
and .capture_identity.pipeline_id == $pipeline
and .capture_identity.source_commit_sha == $source
and .capture_identity.target_commit_sha == $target
and .capture_identity.captured_commit_sha == $captured
and .capture_identity.target_branch == $branch)
' >/dev/null
echo 'pixmoat current integration capture: PASS'
Push the branch and open its merge request. The capture uses the merged-result checkout. After reviewing the build, retry only visual-review. The gate prints pixmoat current integration capture: PASS only for the exact pipeline, source, target and captured commit in that job.
When another MR merges, rebase the remaining source branch onto the new target and run a new capture. GitLab’s semi-linear rule prevents that outdated branch from merging while asynchronous status revocation catches up. First complete and accept the capture for the exact target commit. A temporary merged-result commit can differ from GitLab’s final merge commit, so baseline promotion may require a post-merge target capture.
gitlab_premium_or_ultimate_required means the API does not expose the required merged-result pipeline capability. Use a GitLab Premium or Ultimate project; unavailable API fields cannot grant a pass. integration_capture_required means an ordinary branch capture cannot satisfy this policy. source_requires_rebase means the source is behind its target. merge_rules_not_enforced, target_branch_not_protected, and required_visual_jobs_missing identify repository configuration to correct. merge_policy_unavailable means the settings could not be verified; provider failures cannot grant a pass. Missing API fields and projects with 100 or more pipeline jobs or protection rules fail closed in this initial implementation. Merge trains, fork pipelines, administrator overrides, and blocking nonvisual comparisons are outside this beta path.
Block merges
GitLab blocks a merge on a failed pipeline, not on a named job or status. Open Settings > Merge requests and, under Merge checks, select Pipelines must succeed. Then save.
While visual-review is failing, the pipeline is failed and the merge is blocked. This works on every GitLab tier. pixmoat doesn’t use GitLab’s external status checks.
Don’t add allow_failure: true to visual-review. GitLab treats a pipeline whose failing jobs are all allowed to fail as passed, so the merge would go through without a review.
The approve, retry, green loop
A job that already failed doesn’t rerun on its own:
- A reviewer opens the build in pixmoat, approves the changes, then selects Finish review and Confirm.
- In GitLab, open the merge request’s pipeline and retry
visual-review. - The job prints
pixmoat verdict: PASSand passes. The pipeline turns green and the merge is unblocked.
Retrying visual-review only reads the verdict again, so you can retry it as often as you like. If a reviewer rejected a change, the job keeps failing until you push a fix and its build is approved.
Skip pipelines without UI changes
Fragment. To skip both jobs when a merge request doesn’t touch the UI, replace their rules: with these, and change the paths to match your project:
visual-regression:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes: &ui-paths
- "src/**"
- "tests/**"
- "*.css"
visual-review:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes: *ui-paths
The capture job still runs on every default-branch pipeline, so the baselines stay current. Both jobs use the same paths: if only the capture job is skipped, visual-review can’t run. When both are skipped, the pipeline contains only your other jobs and the merge isn’t blocked on a review.
Commit status
pixmoat can also post a commit status to GitLab. It shows as an external stage in the merge request pipeline, and links to the build in pixmoat. You set it up in pixmoat, not in CI:
- In GitLab, create a project access token with the Developer role and the
apiscope (Settings > Access tokens). A personal access token with theapiscope also works. - In pixmoat, open the project’s Settings and the Git Provider tab.
- Set Provider to GitLab and select Enable integration.
- Fill in Base URL (
https://gitlab.com, or your GitLab address), Project path (for examplegroup/project) and Access Token. - Select Save changes. You’ll see “Git provider settings saved.”
- Select Test connection. When the token and project path are right, you’ll see “All checks passed — integration is ready.” Otherwise each failed check says what’s wrong, for example that the token was rejected or the project wasn’t found.
From the next build on, pixmoat posts a status named pixmoat/visual/<project-slug> on the build’s commit:
| When | Status in GitLab |
|---|---|
| The build starts | running |
| The build finishes with nothing to review and current target baselines | passed |
| The build finishes with changes to review | failed, linking to the review page (passed if the project is in report-only mode) |
| The review is finished and approved with current target baselines | passed |
| The build or review finishes after target baselines changed | failed in blocking mode, passed in report-only mode |
| The review is finished and something was rejected | failed |
Status delivery uses a database queue. Failed requests retry with a delay of up to five minutes and resume after a server restart. Workers serialize updates for the same project, commit and pipeline. A newer invalidation remains queued if an older request is still running.
The Playwright client reads CI_PIPELINE_ID and sends it as ci_pipeline_id when it creates a build. The server retains that ID and includes pipeline_id in every GitLab status request, including approval updates and retries. Separate pipelines for the same commit keep separate builds and queue entries. Merged-result captures target their recorded pipeline commit, while the visual build retains the source commit for merge validation.
Upgrade the capture client when deploying this server change. Older branch captures have no recorded pipeline ID, so the server refuses to post their GitLab statuses and logs a diagnostic. Existing integration captures retain their recorded destination through the database migration. The server never retries a failed status request without its pipeline ID. GitLab documents why the ID is needed when multiple pipelines share a commit.
To repair an older status, verify the MR’s current head pipeline and commit, then repost the existing approved visual result with that pipeline_id, the same status name and the same review URL. Do not select the newest pipeline by commit alone. Run a new capture with the updated client for future review updates.
Target baseline changes and replacement captures queue a fresh status check. The server also periodically rechecks captured commits to detect missed source and target changes. Verified integration captures lose their passing status when their source or target commit changes, including captures with no screenshot changes. Historical approval stays visible in pixmoat. Branch captures retain report-only behavior.
The server periodically republishes unchanged results to correct external state after uncertain delivery. External status updates can lag behind a change or a provider outage. They do not replace repository rules that verify the current merge candidate before GitLab accepts a merge. A completed CI job retains its original result.
In blocking mode, if the project has an approval policy, the status stays failed until the review is finished with the policy met. The status name includes the project slug, so two pixmoat projects on the same repository post two separate statuses.
A failed commit status fails the pipeline, so Pipelines must succeed also blocks on it. When the review is approved, the status turns green, but a visual-review job that already failed still needs a retry. See The approve, retry, green loop.
Merge webhook
When a merge request is merged, pixmoat can copy its approved baselines to the target branch. This is merge promotion. It needs a webhook from GitLab. Without it, the next default-branch build shows the changes you already approved as Changed again.
- In pixmoat, on the Git Provider tab, enter a random string as the Webhook Secret (for example the output of
openssl rand -hex 32) and select Save changes. - Copy the Webhook URL shown on the same tab. It looks like
https://app.pixmoat.com/api/projects/<project-slug>/webhooks/gitlab. - In GitLab, open Settings > Webhooks and select Add new webhook.
- Paste the URL, enter the same string as the Secret token, and under Trigger select only Merge request events.
- Select Add webhook.
To check it, merge a merge request whose build is Approved. The build shows a Merged badge in pixmoat, and the target branch’s baselines are updated without another CI run. The Recent deliveries list at the bottom of the Git Provider tab shows each webhook pixmoat received, with its status.
pixmoat promotes a screenshot only if the target branch’s baseline is still the one the reviewer compared against. If another merge request changed the same snapshot and merged first, that snapshot is skipped, and the next target-branch build shows it as Changed for a fresh review.
GitLab sends a webhook for every merge request event. pixmoat acts only on merges and answers the others with "status": "ignored". If GitLab redelivers the same merge, pixmoat answers "status": "already_processed" and changes nothing.
Manual promotion
If a merge webhook was lost, for example because the webhook wasn’t set up yet when the merge request was merged, a project admin can promote the approved build through the API. See Manual promotion.
Test summary and review link (reporter, beta)
The Playwright reporter can also write files for GitLab’s test summary widget, an HTML report you can download from the merge request, and a link to the review page. The reporter is in beta: it can mark snapshots it didn’t upload as removed, and approving those removals retires valid baselines. Read the reporter warning before you use it.
Fragment. The reporter writes pixmoat-report/. Add PIXMOAT_FAIL_ON_BLOCKED to the capture job’s variables:, and replace its script: and artifacts::
visual-regression:
variables:
PIXMOAT_FAIL_ON_BLOCKED: "false" # let visual-review own the result
script:
- npm ci
- npx playwright test --reporter=@s4labs/pixmoat-playwright/reporter
artifacts:
when: always
reports:
dotenv: pixmoat-report/pixmoat.env # PIXMOAT_BUILD_ID and PIXMOAT_REVIEW_URL
junit: pixmoat-report/junit.xml # test summary widget
expose_as: "Visual report"
paths:
- pixmoat-report/
expire_in: 30 days
The reporter finishes the build itself, so remove globalTeardown from playwright.config.ts. pixmoat.env also sets PIXMOAT_BUILD_ID, so visual-review keeps working.
By default the reporter fails the capture job when the build has changes to review. visual-review needs the capture job, so it wouldn’t run. PIXMOAT_FAIL_ON_BLOCKED: "false" keeps the capture job green and leaves the result to visual-review.
To show a link to the review page, add a separate job. If you put environment: on the capture job instead, GitLab shows “Failed to deploy” whenever that job fails:
visual-review-link:
stage: test
image: alpine:3.20
needs:
- job: visual-regression
artifacts: true
script:
- echo "pixmoat review: $PIXMOAT_REVIEW_URL"
environment:
name: visual/$CI_COMMIT_REF_SLUG
url: $PIXMOAT_REVIEW_URL
allow_failure: true
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: always
Troubleshooting
| Symptom | Cause and fix |
|---|---|
visual-regression fails with “Missing required pixmoat environment variables: PIXMOAT_TOKEN” | The variable is protected, missing, or the merge request comes from a fork. See Store the token. |
visual-review fails with HTTP 404 | PIXMOAT_BUILD_ID didn’t reach the job. Check the dotenv path (see Build ID handoff) and that needs: has artifacts: true. |
visual-review fails with HTTP 401 | PIXMOAT_TOKEN is wrong or the token was revoked. Create a new one in pixmoat and update the variable. |
visual-review fails with HTTP 408 | pixmoat took longer than 300 seconds to process the build. Retry the job. |
visual-review still fails after approval | The job didn’t run again. Retry it. If it prints REVIEW or REVIEW_NEW, some screenshots still need a decision. |
| Snapshots show as Removed that you didn’t delete | The capture was split across jobs or some tests were skipped. See Who finishes the build. |
No pixmoat/visual/<project-slug> status appears | Run Test connection on the Git Provider tab and fix the failed check. Also check that Enable integration is selected. |
Webhook returns 401 | The Secret token in GitLab doesn’t match the Webhook Secret in pixmoat, or no Webhook Secret is saved in pixmoat. |
Webhook returns 403 | The merge request’s project isn’t the one in Project path. Enter the path, such as group/project, not the numeric project ID: pixmoat compares it with the path GitLab sends in the webhook. |
Webhook returns 404 | The project slug in the URL is wrong, or the project’s provider isn’t GitLab. Copy the Webhook URL from pixmoat again. |
| Baselines weren’t promoted after a merge | The build wasn’t Approved before the merge, or the webhook isn’t set up. Promote it by hand: see Manual promotion. |