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

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:

  1. 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.
  2. 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.

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.