Difora in your CI pipeline
Install the CLI (Node 24.22.1+): run
npm install --save-dev difora to add
difora from npm (or
read the
GitHub README if
npm does not load) to your project. Commit the package manifest and
lockfile; the npm ci steps below install that version. See
getting started.
Docs · CLI reference · Getting started · Capture screenshots · Feature overview
The difora CLI works on any CI system: it uploads a
directory of PNG screenshots, waits for the comparison and exits
0 (passed or approved) or 1 (changes need
review, rejected, error). Run the installed client with
npx difora.
- How it fits in
- GitHub Actions
- GitLab CI
- Bitbucket Pipelines
- CircleCI, Jenkins, Azure DevOps, …
- Sharded builds
- Merge-base comparisons
- Pull request comments
- Options
- Snapshot naming
How it fits in
Branch and commit are detected automatically from the providers below,
or the local Git checkout when run outside CI. To override detection,
pass
--branch/--commit or set
DIFORA_BRANCH / DIFORA_COMMIT anywhere else.
npx difora doctor prints what is detected and checks the
connection.
Two ways to get the result onto the pull or merge request:
- Server-side (project settings → Repository status): Difora publishes a GitHub check run (via the Difora GitHub App), a GitLab commit status or a Bitbucket build status itself. GitLab and Bitbucket need an access token, which is stored encrypted.
-
From the pipeline (
difora upload … --post-status): the CLI posts the status with the pipeline's own credentials. Use this for self-hosted GitLab or Bitbucket servers the Difora service cannot reach, or when you would rather not hand Difora a token.
Store the project token from Settings → CI tokens as a secret
named DIFORA_TOKEN.
These Playwright examples use Chromium. Install other browsers only if your Playwright projects use them. Build your site before capture, or serve its source directory if no build is needed; see the monorepo and no-build recipes.
GitHub Actions
Save as .github/workflows/visual.yml. This uses the
complete capture setup and
your site's build script, which writes dist/.
name: Visual review
on: { push: { branches: [main] }, pull_request: {} }
jobs:
visual:
runs-on: ubuntu-latest
permissions:
contents: read
statuses: write # only for --post-status
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 } # merge-base comparisons need full history
- uses: actions/setup-node@v5
with: { node-version: 24 }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npx playwright test # configured capture writes ./screenshots/*.png
- run: npx difora upload ./screenshots --post-status
env:
DIFORA_TOKEN: ${{ secrets.DIFORA_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
On pull_request events the CLI reports the PR's
head commit (not the merge commit), so the status lands
on the PR. With the Difora GitHub App installed you can omit
--post-status.
Run pull requests and pushes to main only so a PR branch is
not uploaded twice; each new build counts toward snapshot usage.
GitLab CI
visual:
image: node:24-bookworm
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
script:
- npm ci
- npx playwright install --with-deps chromium
- npm run build
- npx playwright test
- npx difora upload ./screenshots --post-status
# Configure DIFORA_TOKEN and DIFORA_STATUS_TOKEN as masked CI/CD variables.
# DIFORA_STATUS_TOKEN: project access token with the "api" scope.
Merge-request pipelines report
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME / …_SHA;
branch pipelines CI_COMMIT_REF_NAME /
CI_COMMIT_SHA. Server-side statuses:
Repository status → GitLab, repository
group/project, base URL for self-hosted instances (https,
public host).
Bitbucket Pipelines
pipelines:
pull-requests:
'**':
- step:
image: node:24-bookworm
script:
- npm ci
- npx playwright install --with-deps chromium
- npm run build
- npx playwright test
- npx difora upload ./screenshots --post-status
Set DIFORA_TOKEN and, for --post-status,
DIFORA_STATUS_TOKEN (repository access token with
repository:write) as secured repository variables.
Server-side: Repository status → Bitbucket Cloud, repository
workspace/slug, the same token.
CircleCI, Jenkins, Azure DevOps, Buildkite, Drone, Travis
Detection works out of the box (CIRCLE_*,
CHANGE_BRANCH/BRANCH_NAME/GIT_BRANCH,
SYSTEM_PULLREQUEST_SOURCEBRANCH/BUILD_SOURCEBRANCH, BUILDKITE_*,
DRONE_*, TRAVIS_*). These hosts have no status
integration yet; the exit code gates the pipeline. Use
--exit-zero-on-changes if the visual step should be
advisory.
// Jenkinsfile
withCredentials([string(credentialsId: 'difora-token', variable: 'DIFORA_TOKEN')]) {
sh 'npx difora upload ./screenshots'
}
Sharded builds (CLI 0.5.0+)
Each CI job uploads a disjoint set of stable snapshot names into one build. The last shard starts the comparison; all jobs receive the aggregate result. Use the installed CLI release and keep the screenshot directory fresh per job.
GitHub Actions matrix
on: { push: { branches: [main] }, pull_request: {} }
jobs:
visual:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@v5
with: { fetch-depth: 0 } # merge-base comparisons need full history
- uses: actions/setup-node@v5
with: { node-version: 24 }
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npx playwright test --shard=${{ matrix.shard }}/2
- run: npx difora upload ./screenshots --shard ${{ matrix.shard }}/2 --timeout 2100
env:
DIFORA_TOKEN: ${{ secrets.DIFORA_TOKEN }}
GitLab parallel jobs
visual:
image: node:24
parallel: 2
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"'
script:
- npm ci
- npx playwright install --with-deps chromium
- npm run build
- npx playwright test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
- npx difora upload ./screenshots --parallel --timeout 2100
Store DIFORA_TOKEN as a masked CI variable. GitLab uses
one-based indices.
CircleCI parallelism
jobs:
visual:
docker:
- image: cimg/node:24.0
parallelism: 2
steps:
- checkout
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npx playwright test --shard=$((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL
- run: npx difora upload ./screenshots --parallel --timeout 2100
Set DIFORA_TOKEN in the project context. The CLI normalizes
CircleCI's zero-based index.
Identity, retries and timeouts
--shard i/n and DIFORA_SHARD are one-based (up
to 256 nonempty shards). --parallel uses CI variables from
GitLab, Bitbucket, CircleCI, Azure or Buildkite. GitHub matrix jobs,
Jenkins, Travis and Drone need an explicit shard.
doctor prints detection.
Group IDs default to the provider and run ID. Override with
--parallel-id or DIFORA_PARALLEL_ID when a
workflow has multiple suites. Percy
PERCY_PARALLEL_NONCE/TOTAL aliases are accepted; total
alone does not identify the shard. Commit-based fallback prints a
warning because separate runs of the same commit could collide.
Re-uploading a completed group creates the next attempt, preserving unchanged shards. Every shard that joins the retry must finish; comparison starts after a 120-second quiet period. Branch, commit and shard total must match. Keep the partition stable; use a new ID to change it. A GitHub run-attempt replay reuses the same build; an older attempt is rejected. A newer attempt must wait for an uploading predecessor to time out, or use a new ID with all shards.
Duplicate names across shards return HTTP 409, including the conflicting
names. Finalized shards cannot change within that attempt.
--no-wait exits after your own upload. Project settings set
the upload timeout (30 minutes by default);
--shard-timeout overrides it from 1 to 1440 minutes.
Missing shards produce an error, configured notifications and an
operator alert. Retry incomplete uploads from CI before running a
comparison.
Merge-base comparisons (CLI 0.6.0+)
On a feature branch, Difora compares against the newest successful retained build of the baseline branch at the exact merge-base commit. Images added to the baseline branch later do not appear as removals in the older feature branch. Your branch’s approved images and removals still take precedence.
The CLI chooses the target from --base-branch,
DIFORA_BASE_BRANCH, the pull request’s CI metadata, or the
project’s baseline branch. It reads Git history locally and never
fetches or changes your checkout. The detected source commit is used in
PR builds, including CI checkouts with a synthetic merge commit.
- uses: actions/checkout@v5
with: { fetch-depth: 0 } # merge-base comparisons need full history
- run: npx difora upload ./screenshots --base-branch main
env:
DIFORA_TOKEN: ${{ secrets.DIFORA_TOKEN }}
Fetch the target branch as well as full history. Shallow or missing
history produces a warning; if no exact successful build is retained,
the review page visibly reports that it used current branch baselines.
--no-merge-base opts out;
--base-commit <sha> supplies a known merge-base
explicitly. No ancestry lookup is needed on the baseline branch. All
shards must supply the same base branch and commit.
In Settings → Branches, owners can clean a branch’s approved images and removals while keeping build history. The baseline branch and branches with uploads, comparisons or open reviews are protected. Automatic cleanup defaults to 60 days without builds or baseline changes; set the General setting to 0 to disable it.
Options worth knowing
| Option | Effect |
|---|---|
--no-wait |
Return right after the upload (exit 0); the review link is printed |
--timeout <s> |
How long to wait for the comparison (default 300; exit 3 when exceeded — the build continues on the server) |
--exit-zero-on-changes |
Pending review does not fail the pipeline |
--concurrency <n> |
Parallel image uploads (default 4) |
--json |
Final result as JSON on stdout (progress goes to stderr) |
--post-status |
Post pending/final commit status with the pipeline's token |
Exit codes: 0 passed or approved · 1 changes
need review, rejected or error · 2 usage or upload error ·
3 timed out waiting. Network errors, HTTP 429 and 5xx are
retried up to five times with exponential back-off, honouring
Retry-After. Uploads run with a 120 s timeout per image.
Snapshot naming
Snapshot names are the PNG paths relative to the uploaded directory
without the extension:
login/desktop.png → login/desktop. Names are
case-sensitive. Put viewport or browser into the file name
(home@1280.png, home-firefox.png) — they are
not part of the identity otherwise. Symbolic links are skipped. Images
above 30 million pixels are refused.
Pull/merge request summary comments (CLI 0.8.0+)
Connect your repository in project Settings. Difora keeps a summary
comment updated for the latest completed build on GitHub, GitLab or
Bitbucket Cloud, including counts and a link to review. Comments are
enabled by default and can be disabled by an owner. They are separate
from commit statuses;
--post-status alone does not enable comments.
The CLI detects the PR/MR number from your CI environment. Override it
with
--pr 12 or DIFORA_PR_NUMBER=12. When no number
is supplied, Difora looks for a single open PR with the exact head
commit; supply the number if several PRs share that commit. Parallel
shards must agree.
- GitHub: the Difora App needs Checks and Pull requests read & write. Existing installers must accept the added Pull requests permission once.
-
GitLab: a project access token with the
apiscope and permission to create/update merge request notes. -
Bitbucket Cloud: add
pullrequest:writeto the repository token'srepository:writepermission, including required read access.
Screenshot thumbnails are off by default. An owner may opt in to include up to six 480-pixel previews. Anyone with a preview's random 256-bit URL can view it. GitHub's image proxy caches images in the US; other repository hosts may also copy them. Opt-out and deletion revoke our URLs but cannot erase third-party copies. Full screenshots and review pages remain restricted to your project. Keep thumbnails off when images must remain exclusively on Difora's EU infrastructure.