Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ permissions:
id-token: write
actions: read
contents: read
statuses: read # Needed to check if the visual regression test result has been overriden
deployments: write

jobs:
Expand Down Expand Up @@ -127,3 +128,50 @@ jobs:
test-utils-artifact-name: test-utils-selectors
baseline-artifact-name: visual-baseline-pages
caller-run-id: ${{ github.run_id }}
commit-sha: ${{ github.event.pull_request.head.sha }}

# Required status check for branch protection. Runs for every PR so the context
# is always reported.
#
# It passes when any of these hold:
# - the PR is from a fork (visual regression cannot run, so it is skipped);
# - the visual job succeeded;
# - the commit has a maintainer override status.
#
# The override is checked directly here (not only via the visual job result) so
# that after an override this job can be re-run on its own to turn the check
# green, without rebuilding or redeploying the (unchanged) pages.
visual-regression-result:
name: Visual regression result
needs: [visual]
if: always()
runs-on: ubuntu-latest
steps:
- name: Evaluate result
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ github.event.pull_request.head.sha }}
IS_FORK: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
VISUAL_RESULT: ${{ needs.visual.result }}
run: |
if [ "$IS_FORK" = "true" ]; then
echo "::notice::Fork pull request: visual regression does not run. Marking as passed."
exit 0
fi

if [ "$VISUAL_RESULT" = "success" ]; then
echo "Visual regression passed."
exit 0
fi

OVERRIDDEN=$(gh api \
"repos/${REPO}/commits/${SHA}/statuses" \
--jq 'any(.[]; .context == "visual-regression-override" and .state == "success")' 2>/dev/null || echo "false")
if [ "$OVERRIDDEN" = "true" ]; then
echo "::notice::Visual regression was overridden for this commit by a maintainer. Marking as passed."
exit 0
fi

echo "::error::Visual regression did not pass (result: ${VISUAL_RESULT}). If the differences are intentional, comment '/override-visual-regression <justification>' on the PR."
exit 1
124 changes: 124 additions & 0 deletions .github/workflows/visual-regression-override.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: Override visual regression

# Overrides the mandatory visual regression check when the visual differences are
# intentional.
#
# The override is scoped to a single commit: pushing a new commit produces a new
# SHA with no override status, so the comparison runs again.
#
# Two ways to trigger it:
# 1. Comment `/override-visual-regression <justification>` on the pull request
# (right from the PR page). Only users with write or admin access can do this.
# 2. Run it manually from the Actions tab, passing a commit SHA.

on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
commit-sha:
description: 'The commit SHA whose visual changes are intentional.'
required: true
type: string

# Run one override at a time per PR (comment) or per commit (manual dispatch).
concurrency:
group: visual-regression-override@${{ github.event.issue.number || inputs.commit-sha }}
cancel-in-progress: true

permissions:
statuses: write # Needed to post the override commit status
actions: write # Needed to re-run the deploy workflow

jobs:
override:
name: Apply visual regression override
# Override comments must be posted on a PR and start with the command; the trailing
# space requires text after it, which serves as the override justification
# (for example: "/override-visual-regression Expected padding changes in Container border radii").
# Manual runs (workflow_dispatch) skip the comment check.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/override-visual-regression '))
runs-on: ubuntu-latest
steps:
- name: Authorize commenter
if: github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
ACTOR: ${{ github.actor }}
run: |
PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" --jq '.permission' 2>/dev/null || echo "none")
echo "@${ACTOR} has '${PERMISSION}' permission."
case "$PERMISSION" in
admin|write|maintain)
echo "Authorized." ;;
*)
echo "::error::@${ACTOR} is not authorized to override the visual regression check (requires write access)."
exit 1 ;;
esac

- name: Resolve the commit SHA
id: resolve
if: success()
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
INPUT_SHA: ${{ inputs.commit-sha }}
COMMENT_PR: ${{ github.event.issue.number }}
run: |
if [ "$EVENT_NAME" = "issue_comment" ]; then
# Resolve the head commit of the PR the command was posted on.
RESOLVED=$(gh api "repos/${REPO}/pulls/${COMMENT_PR}" --jq '.head.sha')
else
# workflow_dispatch: the SHA is provided directly.
RESOLVED="$INPUT_SHA"
fi

if [ -z "$RESOLVED" ]; then
echo "::error::Could not resolve a commit SHA to override."
exit 1
fi

echo "Overriding visual regression for commit ${RESOLVED}."
echo "sha=${RESOLVED}" >> "$GITHUB_OUTPUT"

- name: Post override commit status
if: steps.resolve.outputs.sha != ''
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ steps.resolve.outputs.sha }}
ACTOR: ${{ github.actor }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api --method POST "repos/${REPO}/statuses/${SHA}" \
-f state="success" \
-f context="visual-regression-override" \
-f description="Visual changes reviewed and approved by @${ACTOR}" \
-f target_url="${RUN_URL}" >/dev/null
echo "Posted visual-regression-override success status on ${SHA}, approved by @${ACTOR}."

- name: Re-run the failed check to pick up the override
if: steps.resolve.outputs.sha != ''
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ steps.resolve.outputs.sha }}
run: |
RUN_ID=$(gh api \
"repos/${REPO}/actions/workflows/deploy.yml/runs?head_sha=${SHA}&per_page=1" \
--jq '.workflow_runs[0].id // empty')

if [ -n "$RUN_ID" ]; then
# Re-run only the failed jobs (the "Visual regression result" gate),
# not the whole run, so the build and deploy jobs are not repeated.
echo "Re-running failed jobs of deploy workflow run ${RUN_ID} to re-evaluate the check with the override."
gh api --method POST "repos/${REPO}/actions/runs/${RUN_ID}/rerun-failed-jobs" >/dev/null || \
echo "::warning::Could not automatically re-run the check. Re-run the failed 'Visual regression result' job manually."
else
echo "::warning::No deploy workflow run found for commit ${SHA}. Re-run the failed 'Visual regression result' job manually."
fi
38 changes: 38 additions & 0 deletions .github/workflows/visual-regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ on:
description: 'Name of the artifact containing baseline pages (built by the caller workflow).'
required: true
type: string
commit-sha:
description: 'The commit SHA under test, used to check for a per-commit override.'
required: false
type: string

defaults:
run:
Expand All @@ -24,11 +28,45 @@ permissions:
id-token: write
contents: read
actions: read
statuses: read
deployments: write

jobs:
# Checks whether a maintainer has explicitly approved the visual changes for
# this exact commit via the manual override workflow
# (.github/workflows/visual-regression-override.yml). The approval is a commit
# status posted on the head SHA, so it applies only to that commit: a new push
# produces a new SHA with no override and the comparison runs again.
check-override:
name: Check for commit override
runs-on: ubuntu-latest
outputs:
overridden: ${{ steps.status.outputs.overridden }}
steps:
- name: Look for override commit status
id: status
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ inputs.commit-sha }}
run: |
if [ -z "$SHA" ]; then
echo "No commit SHA provided; treating as not overridden."
echo "overridden=false" >> "$GITHUB_OUTPUT"
exit 0
fi
OVERRIDDEN=$(gh api \
"repos/${REPO}/commits/${SHA}/statuses" \
--jq 'any(.[]; .context == "visual-regression-override" and .state == "success")' 2>/dev/null || echo "false")
echo "Override status present for ${SHA}: ${OVERRIDDEN}"
echo "overridden=${OVERRIDDEN}" >> "$GITHUB_OUTPUT"

visual:
name: Visual regression (shard ${{ matrix.shard }})
needs: check-override
# Skip the (expensive) screenshot comparison when the changes have been
# explicitly overridden by a maintainer.
if: ${{ needs.check-override.outputs.overridden != 'true' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
Expand Down
42 changes: 37 additions & 5 deletions docs/RUNNING_TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,46 @@ The deploy workflow (`.github/workflows/deploy.yml`) orchestrates the full pipel

The visual regression workflow (`.github/workflows/visual-regression.yml`):

1. Resolves the PR deployment URL from the GitHub Deployments API.
2. Serves the baseline pages locally.
3. Runs the test suite sharded across multiple runners. Each test navigates to a page on both hosts, captures screenshots, and compares them pixel-by-pixel.
4. Produces an Allure report with image diffs for any failures, deployed to a preview environment.
1. Checks the commit for a `visual-regression-override` status (see [Overriding](#overriding-intentional-visual-changes) below).
2. Resolves the PR deployment URL from the GitHub Deployments API.
3. Serves the baseline pages locally.
4. Runs the test suite sharded across multiple runners. Each test navigates to a page on both hosts, captures screenshots, and compares them pixel-by-pixel.
5. Produces an Allure report with image diffs for any failures, deployed to a preview environment.
6. The deploy workflow's `Visual regression result` job surfaces the pass/fail outcome as the required check.

### Mandatory check

The `Visual regression result` check (from `deploy.yml`) is a **required check**: a PR
cannot merge unless it passes. It passes only when every shard passed, or when the
commit was overridden (below). The reporting jobs run with `always()` and never block
on their own.

Fork pull requests are an exception: visual regression cannot run for forks (the deploy
and baseline jobs are skipped), so the check passes automatically for them.

> To enforce this, add the `Visual regression result` status check to the branch
> protection rules for `main`.

### Reviewing failures

When the CI job fails, check the deployed Allure report (linked from the GitHub deployment). It shows expected vs actual vs diff images for each failing test. If the diff is expected (intentional visual change), note it in your PR description.
When the CI job fails, check the deployed Allure report (linked from the GitHub deployment). It shows expected vs actual vs diff images for each failing test. If the diff is expected (intentional visual change), override the check as described below.

### Overriding intentional visual changes

Because baselines are rebuilt from `origin/main` at run time (there are no committed
screenshots to update), an intentional visual change is approved by overriding the
check rather than by updating snapshots. This mirrors the manual "update workflow"
pattern from [Vitest visual regression testing](https://vitest.dev/guide/browser/visual-regression-testing.html#the-update-workflow).

The override is **scoped to a single commit**. It approves the exact SHA you reviewed;
pushing a new commit produces a new SHA with no override, so the comparison runs again.

To override, first confirm the diffs in the Allure report are intentional, then use either method:

- **From the PR page (recommended):** comment `/override-visual-regression` on the pull request. Only users with write access can trigger it; the head commit is used automatically.
- **From the Actions tab:** run the **Override visual regression** workflow (`.github/workflows/visual-regression-override.yml`) manually, passing the commit SHA to approve.

Either way the workflow posts a `visual-regression-override` success commit status on that SHA, comments on the PR crediting the person who triggered it, and re-runs the deploy workflow. On the re-run, the screenshot comparison is skipped and the `Visual regression result` check passes.

### Adding tests for a new component

Expand Down
Loading
Loading