Skip to content

docs(SDK-1129): keep alpha/gated events out of stable components' @ev… #12572

docs(SDK-1129): keep alpha/gated events out of stable components' @ev…

docs(SDK-1129): keep alpha/gated events out of stable components' @ev… #12572

Workflow file for this run

name: CI
on:
push:
# Exclude the merge queue's ephemeral branches: the merge_group event
# below already runs CI on the exact commit being merged, so letting the
# branch push trigger a second run doubles load and stamps a duplicate
# `test` status on the merge commit — a flake in that redundant run evicts
# the PR from the queue even when the merge_group run passed.
branches-ignore:
- 'gh-readonly-queue/**'
merge_group:
workflow_dispatch:
# Limit the permissions of the GITHUB_TOKEN
permissions:
contents: read
jobs:
# Setup job: Install dependencies and cache them for parallel jobs
setup:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Generate cache key
id: cache-key
run: echo "key=node-modules-${{ hashFiles('package-lock.json') }}" >> $GITHUB_OUTPUT
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v5
with:
path: node_modules
key: ${{ steps.cache-key.outputs.key }}
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
# derive-models: Generate the API models barrel (src/models/external.ts) exactly once.
# Both `build` (→ dist → API report) and `derive-check-src` (→ TypeDoc) consume this single
# artifact, so every derived output in a run reflects the same barrel and derive-commit
# pushes a self-consistent set. On main/merge_group/dependabot a stale barrel fails here.
derive-models:
needs: [setup]
runs-on: ubuntu-latest
outputs:
stale: ${{ steps.check.outputs.stale }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Generate API models barrel
run: npm run models:derive
- name: Check for stale barrel
id: check
run: |
git add src/models/external.ts
if git diff --staged --quiet; then
echo "stale=false" >> $GITHUB_OUTPUT
else
if [[ "$GITHUB_REF" == "refs/heads/main" ]] || [[ "$GITHUB_ACTOR" == "dependabot[bot]" ]] || [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
echo "::error::src/models/external.ts is stale — run 'npm run derive' locally and commit the result."
exit 1
fi
echo "stale=true" >> $GITHUB_OUTPUT
fi
- name: Upload derived models barrel
uses: actions/upload-artifact@v6
with:
name: derived-models
path: src/models/external.ts
retention-days: 1
if-no-files-found: error
# derive-check-i18n: Generate the i18n types declaration (src/i18n/types.d.ts) exactly once.
# `derive-check-src` (→ TypeDoc, which reads this file for the Resources reference) consumes
# this single artifact so the docs reflect the same translations as the checked-in declaration
# and derive-commit pushes a self-consistent set. On main/merge_group/dependabot a stale
# declaration fails here.
derive-check-i18n:
needs: [setup]
runs-on: ubuntu-latest
outputs:
stale: ${{ steps.check.outputs.stale }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Generate i18n types
run: npm run i18n:generate
- name: Check for stale i18n types
id: check
run: |
git add src/i18n/types.d.ts
if git diff --staged --quiet; then
echo "stale=false" >> $GITHUB_OUTPUT
else
if [[ "$GITHUB_REF" == "refs/heads/main" ]] || [[ "$GITHUB_ACTOR" == "dependabot[bot]" ]] || [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
echo "::error::src/i18n/types.d.ts is stale — run 'npm run derive' locally and commit the result."
exit 1
fi
echo "stale=true" >> $GITHUB_OUTPUT
fi
- name: Upload derived i18n types
uses: actions/upload-artifact@v6
with:
name: derived-i18n
path: src/i18n/types.d.ts
retention-days: 1
if-no-files-found: error
# Build job: Build the project and cache artifacts
build:
needs: [setup, derive-models]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Cache build artifacts
id: cache-build
uses: actions/cache@v5
with:
path: dist
key: build-${{ github.sha }}
# Compile dist from the single canonical barrel so the API report (derived from
# dist) reflects the same external.ts as the docs.
- name: Download API models barrel
if: steps.cache-build.outputs.cache-hit != 'true'
uses: actions/download-artifact@v6
with:
name: derived-models
path: src/models
- name: Generate i18n translations
if: steps.cache-build.outputs.cache-hit != 'true'
run: npm run i18n:generate
- name: Build
if: steps.cache-build.outputs.cache-hit != 'true'
run: npx vite build
# Lint job: Run ESLint (parallel with other checks)
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Lint
run: npm run lint:check
# Format job: Run Prettier check (parallel with other checks)
format:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Format check
run: npm run format:check
# TypeScript job: Run type checking (parallel with other checks)
typecheck:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: TypeScript check
run: npm run tsc
# derive-check-src: Run endpoint inventory and TypeDoc checks on a standard runner.
# Neither reads from dist/, so this job runs in parallel with `build`.
# On regular branches: outputs stale=true if files changed (derive-commit handles the push).
# On main, merge_group, and dependabot: fails immediately if files are stale.
derive-check-src:
needs: [setup, derive-models, derive-check-i18n]
runs-on: ubuntu-latest
outputs:
stale: ${{ steps.check.outputs.stale }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Install docs-site dependencies
run: npm ci
working-directory: docs-site
# TypeDoc reads src/models/external.ts; use the single canonical barrel from
# derive-models so the docs reflect the same external.ts as the API report.
- name: Download API models barrel
uses: actions/download-artifact@v6
with:
name: derived-models
path: src/models
# TypeDoc reads src/i18n/types.d.ts for the Resources reference; use the freshly
# generated declaration from derive-check-i18n so the docs reflect the same
# translations that job verified.
- name: Download i18n types
uses: actions/download-artifact@v6
with:
name: derived-i18n
path: src/i18n
# endpoints:derive must finish first: the TypeDoc theme reads the generated
# endpoint-inventory.json to render the Endpoints section on each page, so
# docs:api:generate depends on it and the two can no longer run in parallel.
- name: Derive endpoint inventory, then TypeDoc
run: |
npm run endpoints:derive
npm run docs:api:generate
- name: Check for stale derived files
id: check
run: |
git add docs/guides
git add docs/reference
if git diff --staged --quiet; then
echo "stale=false" >> $GITHUB_OUTPUT
else
if [[ "$GITHUB_REF" == "refs/heads/main" ]] || [[ "$GITHUB_ACTOR" == "dependabot[bot]" ]] || [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
echo "::error::Derived files are stale — run 'npm run derive' locally and commit the result."
exit 1
fi
echo "stale=true" >> $GITHUB_OUTPUT
fi
- name: Upload derived src files
uses: actions/upload-artifact@v6
with:
name: derived-src
path: |
docs/guides
docs/reference
retention-days: 1
if-no-files-found: error
# derive-check-dist: Run API report check on a standard runner.
# api-extractor reads dist/index.d.ts, so this job runs after `build`.
# On regular branches: outputs stale=true if the report changed (derive-commit handles the push).
# On main, merge_group, and dependabot: fails immediately if the report is stale.
derive-check-dist:
needs: [setup, build]
runs-on: ubuntu-latest
outputs:
stale: ${{ steps.check.outputs.stale }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Restore build artifacts
uses: actions/cache/restore@v5
with:
path: dist
key: build-${{ github.sha }}
- name: Derive API report
run: npm run api-report:derive
- name: Check for stale API report
id: check
run: |
git add .reports
if git diff --staged --quiet; then
echo "stale=false" >> $GITHUB_OUTPUT
else
if [[ "$GITHUB_REF" == "refs/heads/main" ]] || [[ "$GITHUB_ACTOR" == "dependabot[bot]" ]] || [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
echo "::error::API report is stale — run 'npm run derive' locally and commit the result."
exit 1
fi
echo "stale=true" >> $GITHUB_OUTPUT
fi
- name: Upload derived dist files
uses: actions/upload-artifact@v6
with:
name: derived-dist
path: .reports
include-hidden-files: true
retention-days: 1
if-no-files-found: error
# derive-commit: Re-derive and commit any stale files back to the branch.
# Only runs when a check job detected staleness — the expensive self-hosted runner
# (needed for the org IP allow list to push back to the repo) is never used otherwise.
# Main, merge_group, and dependabot are gated out at the check layer (they fail on
# staleness rather than setting stale=true), so derive-commit never runs for them.
derive-commit:
needs: [derive-models, derive-check-i18n, derive-check-src, derive-check-dist]
if: needs.derive-models.outputs.stale == 'true' || needs.derive-check-i18n.outputs.stale == 'true' || needs.derive-check-src.outputs.stale == 'true' || needs.derive-check-dist.outputs.stale == 'true'
# Must use the self-hosted runner group — GitHub-hosted runners are blocked
# by the org IP allow list and cannot push back to the repository.
runs-on:
group: gusto-ubuntu-default
permissions:
contents: write
steps:
- name: Mint GitHub App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_SECRET }}
- uses: actions/checkout@v6
with:
token: ${{ steps.app-token.outputs.token }}
# Clear the derived directories before applying the artifacts. The
# checkout restores every committed file, including any that the
# generators no longer produce (e.g. a page whose path changed). The
# artifacts only contain freshly-generated files and download-artifact
# overlays rather than replaces, so without this `git add` below would
# never see the orphans as deleted and they'd linger forever. Wiping
# first means the post-download tree is exactly the generator output, so
# `git add` stages the deletions (git rm) alongside the additions.
- name: Clear derived directories before applying artifacts
run: rm -rf docs/guides docs/reference
- name: Download derived src files
uses: actions/download-artifact@v6
with:
name: derived-src
path: docs
- name: Download derived dist files
uses: actions/download-artifact@v6
with:
name: derived-dist
path: .reports
- name: Download derived models barrel
uses: actions/download-artifact@v6
with:
name: derived-models
path: src/models
- name: Download derived i18n types
uses: actions/download-artifact@v6
with:
name: derived-i18n
path: src/i18n
- name: Commit and push derived files
run: |
git add docs/guides
git add docs/reference
git add .reports
git add src/models/external.ts
git add src/i18n/types.d.ts
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if ! git diff --staged --quiet; then
git commit -m "chore: update derived files"
git push
fi
# derive: Single required check that branch protection can gate on.
# Aggregates derive-models, derive-check-src and derive-check-dist. derive-commit is
# excluded intentionally — it only runs when files are stale and is not a health signal.
# Fails when any check job reported stale=true so the gate stays red until
# derive-commit pushes the fix and CI passes on the new commit.
derive:
needs: [derive-models, derive-check-i18n, derive-check-src, derive-check-dist]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Verify derive checks passed
env:
MODELS: ${{ needs.derive-models.result }}
I18N: ${{ needs.derive-check-i18n.result }}
SRC: ${{ needs.derive-check-src.result }}
DIST: ${{ needs.derive-check-dist.result }}
MODELS_STALE: ${{ needs.derive-models.outputs.stale }}
I18N_STALE: ${{ needs.derive-check-i18n.outputs.stale }}
SRC_STALE: ${{ needs.derive-check-src.outputs.stale }}
DIST_STALE: ${{ needs.derive-check-dist.outputs.stale }}
run: |
if [[ "$MODELS" != "success" && "$MODELS" != "skipped" ]]; then exit 1; fi
if [[ "$I18N" != "success" && "$I18N" != "skipped" ]]; then exit 1; fi
if [[ "$SRC" != "success" && "$SRC" != "skipped" ]]; then exit 1; fi
if [[ "$DIST" != "success" && "$DIST" != "skipped" ]]; then exit 1; fi
if [[ "$MODELS_STALE" == "true" || "$I18N_STALE" == "true" || "$SRC_STALE" == "true" || "$DIST_STALE" == "true" ]]; then
echo "::error::Derived files were stale — an auto-fix commit may be in progress. Re-run CI after it lands, or run 'npm run derive' locally."
exit 1
fi
# Storybook build job: validates all stories compile and index successfully
storybook-build:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Build Storybook
run: npm run build-storybook
# Docs build job: validates the Docusaurus site compiles, catching broken
# links/anchors (onBrokenLinks: 'throw') at PR time rather than at release
# time. Uses its own node_modules under docs-site/ — not the root cache.
docs-build:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Validate docs front matter
run: npm run docs:lint
- name: Lint markdown
run: npm run docs:lint:markdown
- name: Spell check docs
run: npm run docs:lint:spell
- name: Install docs dependencies
working-directory: docs-site
run: npm ci
- name: Build docs
run: npm run docs:build
# SDK app build job: validates the dev app's vite config and entry compile
sdk-app-build:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Build SDK app
run: npm run sdk-app:build
# Test job: Run tests with coverage (parallel with other checks)
test:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Test with coverage
run: npm run test:ci
# Scenarios job: Validate scenario JSON against schema and run scenario unit tests.
# E2E jobs depend on this so a broken scenario fails fast (cheap) before Playwright runs (expensive).
scenarios:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
- name: Validate scenario JSON
run: npm run scenarios:validate
- name: Run scenario unit tests
run: npm run test:scenarios
# E2E discover: Decide which domain shards should run for this event.
#
# Domains are discovered dynamically from `e2e/tests/<domain>/`. The
# naming contract is `e2e/tests/<kebab>` ↔ `src/components/<Pascal>`
# (e.g. time-off ↔ TimeOff). Adding or removing a domain folder is the
# only thing required to add or remove a shard.
#
# The selection is a two-stage filter:
#
# 1. Drop files whose extension can't affect runtime or test
# behavior (.md, LICENSE, .gitignore, images, etc.). A docs-only
# PR produces an empty filtered diff and skips e2e entirely.
# 2. Of the surviving files, if any sit OUTSIDE every known domain's
# surface area (`src/components/<Pascal>/` or `e2e/tests/<kebab>/`),
# treat the change as cross-cutting and run every shard.
# Otherwise run only the domains whose surface was actually touched.
#
# Stage 2 is safe by default: new code in unfamiliar locations (e.g. a
# fresh top-level directory like `src/middleware/`) fans out to all
# shards rather than silently producing an empty matrix.
#
# On pushes to main and manual dispatches we run every domain
# unconditionally — main is the canonical "all green" signal regardless
# of which paths landed in the merge commit.
#
# Output is a JSON array of domain folder names that downstream
# `e2e-setup` and `e2e` consume via fromJson() for matrix expansion. An
# empty array is a valid result and short-circuits both downstream jobs.
e2e-discover:
needs: setup
runs-on: ubuntu-latest
outputs:
domains: ${{ steps.resolve.outputs.domains }}
steps:
- uses: actions/checkout@v6
with:
# Need merge-base history to diff PR branches against base. Default
# depth=1 has no shared history with origin/<base>, which would
# make `git diff base...HEAD` empty and silently skip every shard.
fetch-depth: 0
- id: resolve
name: Resolve domains JSON
env:
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
BASE_REF: ${{ github.base_ref }}
run: bash .github/scripts/e2e-discover-domains.sh
# E2E setup: Provision demo companies once per CI run and share state across
# shards. E2E_DISABLE_CACHE ensures fresh provisioning for each CI run
# (ignores stale .e2e-state.json from previous runs), but the artifact sharing
# across shards within this run avoids duplicate provisioning load on the
# demo backend. The set of domains to run is computed by `e2e-discover`
# and passed through unchanged so downstream `e2e` consumers have a
# single source of truth. Skipped entirely when `e2e-discover` produced
# an empty array (a PR with no e2e-affecting changes).
e2e-setup:
needs: [setup, scenarios, e2e-discover]
if: needs.e2e-discover.outputs.domains != '[]'
runs-on: ubuntu-latest
# Wallclock ceiling for provisioning all demo companies (primary +
# dismissal + every shared scenario in e2e/scenarios/shared/). Each
# scenario gets up to 180s of patient polling for the demo backend's
# background seeding to complete. With 2 shared scenarios + 2 legacy
# companies, worst case is ~12 min; 15 leaves a small margin. If this
# is consistently hitting the cap, the demo factory is degraded —
# surface that here rather than smearing the failure across every
# downstream shard.
timeout-minutes: 15
outputs:
domains: ${{ needs.e2e-discover.outputs.domains }}
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
# Pin the Playwright browser cache key to the actual playwright version
# (not the lockfile hash) so unrelated dependency churn doesn't bust the
# ~250MB chromium download. Cache only invalidates on real Playwright
# upgrades.
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./node_modules/playwright/package.json').version")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright system dependencies only
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Provision demo companies
env:
E2E_USE_REAL_BACKEND: 'true'
E2E_DISABLE_CACHE: 'true'
run: npm run e2e:setup
- name: Upload e2e state
uses: actions/upload-artifact@v6
with:
name: e2e-state
# Includes both the legacy primary/dismissal state (used by
# dismissal.spec.ts) and the shared scenarios artifact (used by
# all scenario-annotated tests via the localTestFixture).
# Provision-once / share-many model: every scenario is created and
# validated here, every shard just downloads and reads.
path: |
e2e/.e2e-state.json
e2e/.e2e-scenarios.json
include-hidden-files: true
retention-days: 1
if-no-files-found: error
# E2E job: Run Playwright e2e tests against the live demo environment.
# This is the sole Playwright gate. A previous MSW-mode e2e job was removed
# because its mock fixtures could not keep pace with real demo backend
# behavior, and the duplicate run added CI minutes for shallow coverage
# already provided by Storybook + unit tests. This job exercises the
# actual API contract, scenario provisioning, and state machine terminal
# states.
#
# Sharded by domain so each domain runs in parallel and one domain's
# failure doesn't block feedback on the others. The matrix is built
# dynamically by the `e2e-setup` job from the subfolders under
# `e2e/tests/`, so adding a new domain folder is sufficient to add a
# shard. The `domain` filter is a Playwright path-substring match against
# specs under `e2e/tests/<domain>/**/*.spec.ts`.
e2e:
needs: [setup, scenarios, e2e-setup]
# Skip cleanly when no domain folders are selected — fromJson on an empty
# array would otherwise fail matrix expansion. The selection itself
# (including the "always run all on main" rule) is computed in
# `e2e-discover`; we just honor whatever it produced.
if: needs.e2e-setup.outputs.domains != '[]'
runs-on: ubuntu-latest
# Wallclock ceiling per shard. Scenario provisioning is now done in
# e2e-setup, so shards just download the artifact and run tests against
# pre-provisioned companies — no demo creation latency during the test
# run. 20m is comfortable for the largest expected shard (the canaries
# shard, which runs all 17 canary specs serially across 5 domains on
# one shared company) with margin for slow demo backend. Tight enough
# that a stuck run releases CI minutes quickly.
timeout-minutes: 20
strategy:
fail-fast: false
# Concurrent shard ceiling against flows.gusto-demo.com. We only have 3
# domains today (employee/time-off/payroll), so 4 effectively means "no
# throttle — let every shard run in parallel". The matrix is generated
# dynamically from e2e/tests/<domain>/, so this gives headroom for one
# more domain without needing another bump. Each shard still runs with
# workers: 1 in playwright.demo.config.ts, so per-shard backend load is
# unchanged.
max-parallel: 4
matrix:
domain: ${{ fromJson(needs.e2e-setup.outputs.domains) }}
name: e2e (${{ matrix.domain }})
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
- name: Restore node_modules cache
uses: actions/cache/restore@v5
with:
path: node_modules
key: ${{ needs.setup.outputs.cache-key }}
# Pin the Playwright browser cache key to the actual playwright version
# (not the lockfile hash) so unrelated dependency churn doesn't bust the
# ~250MB chromium download. Cache only invalidates on real Playwright
# upgrades.
- name: Resolve Playwright version
id: playwright-version
run: |
version=$(node -p "require('./node_modules/playwright/package.json').version")
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
restore-keys: |
playwright-${{ runner.os }}-
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Install Playwright system dependencies only
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Download e2e state
uses: actions/download-artifact@v6
with:
name: e2e-state
path: e2e/
- name: Run e2e tests
env:
E2E_USE_REAL_BACKEND: 'true'
# Path-bounded grep so each shard runs only the specs directly under
# its own top-level directory. Bare `${{ matrix.domain }}` would
# substring-match any path containing that word — so the `company`
# shard would also pick up `canaries/company/`. Playwright matches
# the positional regex against absolute file paths; `tests/<domain>/`
# is the smallest disambiguating substring that anchors to the
# top-level directory without leaking into nested duplicates.
run: npm run test:e2e:demo -- --pass-with-no-tests "tests/${{ matrix.domain }}/"
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: playwright-report-${{ matrix.domain }}
path: playwright-report/
retention-days: 7
- name: Upload scenario reports
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: e2e-scenario-report-${{ matrix.domain }}
path: e2e/reports/
retention-days: 7
# E2E success: Single required check that branch protection can gate on.
#
# Aggregates the result of every job that collectively constitutes "e2e
# passed" — scenarios validation, the provisioning job, and the matrix
# itself (whose `result` is the rolled-up status across every shard).
# Skipped counts as success so a PR that only touches paths outside any
# domain (and therefore produced an empty matrix) still satisfies the
# gate. Failure or cancellation of any contributing job fails this check.
#
# `if: !cancelled()` is required: without it, a failed upstream short-
# circuits this job to `skipped`, which branch protection treats as
# passing — exactly the bug we're trying to avoid.
e2e-success:
needs: [scenarios, e2e-discover, e2e-setup, e2e]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Verify required e2e jobs succeeded
env:
SCENARIOS: ${{ needs.scenarios.result }}
E2E_DISCOVER: ${{ needs.e2e-discover.result }}
E2E_SETUP: ${{ needs.e2e-setup.result }}
E2E: ${{ needs.e2e.result }}
run: bash .github/scripts/e2e-verify-success.sh