Skip to content

fix(sync): count a Property with a recorded failure in the reconcilia… #3470

fix(sync): count a Property with a recorded failure in the reconcilia…

fix(sync): count a Property with a recorded failure in the reconcilia… #3470

Workflow file for this run

name: CI
on:
workflow_dispatch:
push:
branches: [main]
pull_request:
branches: [main]
# Without this, every push left the previous run of this workflow burning a
# runner: three beta-acceptance jobs ran concurrently on superseded commits and
# starved each other, so the oldest reached its 120-minute timeout without
# finishing. Superseded PR runs are cancelled; main is never cancelled, because
# its runs gate the release.
#
# Because main's runs queue one at a time, every job carries `timeout-minutes`
# (about 2-3x its longest green run): without one, a hung job holds the queue
# for GitHub's 6-hour default. On 2026-10-16 storybook-test hung twice and held
# every later merge and deploy until it was cancelled by hand.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
contents: read
pull-requests: read
checks: write
security-events: write
jobs:
# ~5.3m measured: independent source and policy gates need no services.
static:
runs-on: ubuntu-latest
timeout-minutes: 20 # longest green run ~8 min
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# BQC-6.9: the changed-code budget diffs added src files against
# origin/main — the check job needs full history.
fetch-depth: 0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# BQC-7.7: supply-chain gates — hard failures (no continue-on-error).
# Thresholds + exception policy: docs/operations/security-ci-policy.md
- name: Dependency vulnerability audit
run: pnpm check:dependency-audit
- name: License policy
run: pnpm check:licenses
- name: Action/image pin policy
run: pnpm check:action-pins
- name: Container image inventory and coverage policy
run: pnpm check:container-images
- name: Format check
run: pnpm format:check
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint:ci
# Fast unit feedback is split by Vitest's file-balanced native shard. Each
# matrix row reports its own failing files while all four run concurrently.
test-unit:
name: test-unit (${{ matrix.shard }}/4)
runs-on: ubuntu-latest
timeout-minutes: 10 # longest green run ~3 min
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Test unit shard ${{ matrix.shard }}/4
env:
NODE_ENV: test
DATABASE_URL: postgresql://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
RESEND_API_KEY: re_ci_test_key_placeholder
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
run: pnpm exec vitest run --project=unit --shard=${{ matrix.shard }}/4
# ~4.6m measured test work plus migration gates: integration owns the shared
# scratch database; vitest.config.ts explicitly marks unit DB/Redis-free.
test-integration:
runs-on: ubuntu-latest
timeout-minutes: 25 # longest green run ~10 min
services:
postgres:
# BQC-7.7: digest-pinned (multi-arch index) — tag comment for humans.
image: postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 # 16.14
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
# BQC-7.7: digest-pinned (multi-arch index) — tag comment for humans.
image: redis:7@sha256:595cc6f2bb3af6e03347b90deb6123c6aa2c81dea05ce08128de8a174b6ac67b # 7.4.10
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# BQC-6.9: the changed-code budget diffs added src files against
# origin/main — the check job needs full history.
fetch-depth: 0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Run migrations
env:
NODE_ENV: test
DATABASE_URL: postgresql://test:test@localhost:5432/test
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
# BQC-5.4: deploy apply order — Better Auth, then the Drizzle
# journal — so the tested DB matches deploy state.
run: |
pnpm auth:migrate
pnpm db:migrate
# BQC-7.1: predeploy migration parity — the deploy runner
# (scripts/migrate-deploy.ts, the Railway preDeployCommand) must
# converge instantly against the DB the manual sequence just migrated.
# Exit 0 = the deploy path and the documented manual path reach the
# same end state; guards drift between the two on every PR.
- name: Predeploy migration parity
env:
NODE_ENV: test
DEPLOY_MIGRATE: '1'
DATABASE_URL: postgresql://test:test@localhost:5432/test
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
# One-run sealed migrator key. CI migrates an empty database, so the
# provider-subject inventory has no seeded row and the initializer
# fails closed (provider_subject_key_initialization_invalid) unless a
# migrator key is injected exactly once — the same contract production
# follows, with the key removed before normal services start.
REVIEW_PROVIDER_SUBJECT_HMAC_MIGRATOR_KEYS: 'v1:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd'
run: pnpm db:migrate-deploy
# BQC-7.7: migration artifact consistency as a NAMED gate — the semantic
# model↔catalog comparator (src/shared/db/schema-drift.ts) against the DB
# the migration steps just brought to deploy state.
- name: Schema drift (model ↔ migrated catalog)
env:
NODE_ENV: test
DATABASE_URL: postgresql://test:test@localhost:5432/test
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
run: pnpm check:schema-drift
# Pull requests and main run the identical test pair: the unit project
# in the four test-unit shards above, the integration project here.
# Neither runs under coverage and no coverage floor is enforced: the
# BQC-6.9 `check:coverage` ratchet was deleted on 2026-09-05.
- name: Test
env:
NODE_ENV: test
DATABASE_URL: postgresql://test:test@localhost:5432/test
REDIS_URL: redis://localhost:6379
# This job has the Redis service above, so the Redis-backed suites
# must run. Without this an unanswered probe makes them return before
# their first assertion and report green
# (src/shared/testing/redis-test-lease.ts). Only a job with a Redis
# service may set it; redis-test-lease.test.ts pins both halves.
REQUIRE_TEST_REDIS: '1'
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
RESEND_API_KEY: re_ci_test_key_placeholder
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
run: pnpm test:integration
# Build output and every consumer/boundary stay in one chain.
# The booted security-header probe needs Redis, but no PostgreSQL.
artifacts:
runs-on: ubuntu-latest
timeout-minutes: 10 # longest green run ~2 min
services:
redis:
# BQC-7.7: digest-pinned (multi-arch index) — tag comment for humans.
image: redis:7@sha256:595cc6f2bb3af6e03347b90deb6123c6aa2c81dea05ce08128de8a174b6ac67b # 7.4.10
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# BQC-6.9: the changed-code budget diffs added src files against
# origin/main — the check job needs full history.
fetch-depth: 0
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# BQC-6.9: changed-code budget — every src production module added by the
# branch (API routes included; pages and components are not) must be
# imported by a test, or by a module a test imports (diffs against
# origin/main; the checkout above uses fetch-depth: 0). That proves a test
# can load the module, not that one asserts anything about it, and it is
# still the only check on new production code landing untested. Measured
# 2-3s. See scripts/check-changed-code.mjs.
- name: Changed-code budget
run: pnpm check:changed-code
- name: Web build
run: pnpm build
env:
NODE_ENV: production
DATABASE_URL: postgresql://test:test@localhost:5432/test
BETTER_AUTH_SECRET: test-secret-at-least-32-characters-long-for-ci
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder-client-id
GOOGLE_CLIENT_SECRET: ci-placeholder-client-secret
ENCRYPTION_KEY: aabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccddaabbccdd
OAUTH_STATE_SECRET: aabbccddaabbccddaabbccddaabbccdd
# BQC-7.6 / STD-P1-07: booted-artifact security gate — boots the built
# server and asserts the full B0.7 header set on 200 AND 404 responses
# (plus x-request-id and the 413 body limit). The script generates its
# own per-run secrets; no placeholder env is passed to the boot.
- name: Security headers (booted artifact)
run: pnpm check:security-headers
# Built-artifact gate for the pinned reply-language runtime: cld3-asm must
# stay external and still load + detect from inside .output/server. When
# it gets bundled, its emscripten loader throws `runtimeModule is not a
# function` and every reply suggestion 500s — invisible to the unit suite,
# which injects a fake detector.
- name: Reply-language runtime (built artifact)
run: pnpm check:language-verifier
# BQC-6.8: client bundle budgets on the production output (main entry /
# initial payload / lazy chunks — see scripts/check-bundle-budget.mjs).
- name: Bundle budget
run: pnpm check:bundles
# The worker build is part of `pnpm build`; the single tsup invocation
# emits the web preload and worker/migration bundles.
# The forbidden-source rules read source maps and nothing else, so the
# two places that must carry maps are named: without them a build that
# stopped emitting maps would pass with nothing inspected. This step is
# the only run that reads the client maps: the image build's may already
# be deleted by the Sentry upload.
- name: Production artifact boundary
run: >-
pnpm check:production-artifacts
--require-source-maps .output/client-source-maps
--require-source-maps dist-worker
.output
dist-worker
# BQC-7.1: artifact digest — deterministic tarball of the deploy
# artifacts (sorted names, zeroed mtimes/owners, gzip -n), digest in
# the step output and job summary as build evidence.
- name: Artifact digest
run: |
tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner -cf - \
.output dist-worker \
| gzip -n > /tmp/artifact.tgz
DIGEST=$(shasum -a 256 /tmp/artifact.tgz | cut -d' ' -f1)
echo "sha256:$DIGEST (all deploy artifacts)"
{
echo "### Build artifact digest"
echo "\`sha256:$DIGEST\` — all deploy artifacts (deterministic tar)"
} >> "$GITHUB_STEP_SUMMARY"
# BQC-7.1: source SBOM (SPDX JSON) retained as a build artifact.
- name: Generate SBOM
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
format: spdx-json
output-file: sbom.spdx.json
- name: Upload SBOM
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-spdx
path: sbom.spdx.json
retention-days: 30
# Aggregate-only seam (no measured workload): retain the required status
# context without allowing a failed, cancelled, or skipped dependency to pass.
check:
needs: [static, test-unit, test-integration, artifacts]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5 # longest green run ~1 min
steps:
- name: Require every check shard to succeed
if: >-
contains(needs.*.result, 'failure') ||
contains(needs.*.result, 'cancelled') ||
contains(needs.*.result, 'skipped')
run: exit 1
# BQC-7.1: the production containers are the single deploy build path
# (nixpacks removed) — prove every classified image builds reproducibly on
# every PR and satisfies its explicit runtime contract.
#
# Two bounded groups keep the two heaviest web and worker builds concurrent.
# Every descriptor still owns its build, smoke, SBOM, and Grype evidence.
docker-images:
name: docker image group (${{ matrix.group }})
runs-on: ubuntu-latest
timeout-minutes: 25 # longest green run ~8 min
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- group: web
images: >-
[{"name":"web","dockerfile":"Dockerfile","tag":"repkey-web:ci","publish":true},{"name":"google-provider-redis","dockerfile":"Dockerfile.google-provider-redis","tag":"repkey-google-provider-redis:ci","publish":true}]
- group: worker-tools
images: >-
[{"name":"worker","dockerfile":"Dockerfile","tag":"repkey-worker:ci","publish":true},{"name":"perf-runner","dockerfile":"Dockerfile.perf-runner","tag":"repkey-perf-runner:ci","publish":false},{"name":"provider-sandbox","dockerfile":"Dockerfile.sandbox","tag":"repkey-provider-sandbox:ci","publish":false}]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Build grouped images
env:
IMAGE_GROUP_JSON: ${{ matrix.images }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
run: |
set -euo pipefail
jq -e 'type == "array" and length > 0 and length <= 4 and all(.[]; (.name | type == "string") and (.dockerfile | type == "string") and (.tag | type == "string") and (.publish | type == "boolean"))' <<<"$IMAGE_GROUP_JSON" >/dev/null
while IFS= read -r image; do
name=$(jq -r '.name' <<<"$image")
dockerfile=$(jq -r '.dockerfile' <<<"$image")
tag=$(jq -r '.tag' <<<"$image")
build_flags=(--build-arg "SOURCE_REVISION=$GITHUB_SHA")
if [[ "$name" == "web" ]]; then
# Org and project NAME the release and are repository variables,
# not secrets, so they stay build arguments. The upload TOKEN is a
# BuildKit secret: a build argument is recorded in image history,
# build provenance and the exported GHA layer cache, which would
# publish it to anyone who can pull or inspect the image.
# `check:container-images` fails the build if a secret-shaped
# value is passed as a build argument again.
build_flags+=(
--build-arg "SENTRY_ORG=$SENTRY_ORG"
--build-arg "SENTRY_PROJECT=$SENTRY_PROJECT"
)
# Absent on forks and on any run without the repository secret;
# the Dockerfile then skips the source-map upload instead of
# failing, so the image still builds.
if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then
build_flags+=(--secret "id=sentry_auth_token,env=SENTRY_AUTH_TOKEN")
fi
fi
docker buildx build \
--load \
--cache-from "type=gha,scope=ci-image-${name}" \
--cache-to "type=gha,mode=max,scope=ci-image-${name}" \
"${build_flags[@]}" \
-f "$dockerfile" \
-t "$tag" \
.
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
- name: Smoke images (contract, non-root, prod-deps purity)
env:
IMAGE_GROUP_JSON: ${{ matrix.images }}
run: |
set -euo pipefail
jq -e 'type == "array" and length > 0 and length <= 4' <<<"$IMAGE_GROUP_JSON" >/dev/null
while IFS= read -r image; do
IMAGE_NAME=$(jq -r '.name' <<<"$image")
IMAGE_TAG=$(jq -r '.tag' <<<"$image")
case "$IMAGE_NAME" in
web)
docker run --rm repkey-web:ci node -e "process.exit(0)"
test "$(docker image inspect repkey-web:ci --format '{{.Config.User}}')" = "node"
docker run --rm --entrypoint test repkey-web:ci -e /app/node_modules/pg
docker run --rm --entrypoint node repkey-web:ci -e "const p=require('/app/package.json');process.exit('scripts' in p?1:0)"
for sentinel in vitest typescript eslint prettier; do
if docker run --rm --entrypoint test repkey-web:ci -e "/app/node_modules/$sentinel"; then
echo "::error::devDependency $sentinel leaked into repkey-web:ci prod node_modules"
exit 1
fi
done
test "$(docker image inspect repkey-web:ci --format '{{index .Config.Labels "com.repkey.google-import-contract"}}')" = "final"
test "$(docker image inspect repkey-web:ci --format '{{index .Config.Labels "com.repkey.rollout-scope"}}')" = "serving-final"
docker run --rm --entrypoint sh repkey-web:ci -c \
"! find /app/.output /app/dist-worker -type f \( -name '*seed-e2e-user*' -o -name '*provision-google-admission-role*' \) -print | grep -q . && ! grep -R -l 'password123' /app/.output /app/dist-worker 2>/dev/null"
;;
worker)
docker run --rm repkey-worker:ci node -e "process.exit(0)"
# web and worker are ONE image now; the image CMD is the web
# server, so prove the worker start command's entrypoints are
# present (railway.worker.json `startCommand` runs them).
docker run --rm --entrypoint node repkey-worker:ci -e \
"require.resolve('/app/dist-worker/index.js');require.resolve('/app/dist-worker/worker-observability-preload.js')"
test "$(docker image inspect repkey-worker:ci --format '{{.Config.User}}')" = "node"
docker run --rm --entrypoint test repkey-worker:ci -e /app/node_modules/pg
docker run --rm --entrypoint node repkey-worker:ci -e "const p=require('/app/package.json');process.exit('scripts' in p?1:0)"
for sentinel in vitest typescript eslint prettier; do
if docker run --rm --entrypoint test repkey-worker:ci -e "/app/node_modules/$sentinel"; then
echo "::error::devDependency $sentinel leaked into repkey-worker:ci prod node_modules"
exit 1
fi
done
test "$(docker image inspect repkey-worker:ci --format '{{index .Config.Labels "com.repkey.google-import-contract"}}')" = "final"
test "$(docker image inspect repkey-worker:ci --format '{{index .Config.Labels "com.repkey.rollout-scope"}}')" = "serving-final"
docker run --rm --entrypoint sh repkey-worker:ci -c \
"! find /app/dist-worker -type f \( -name '*seed-e2e-user*' -o -name '*provision-google-admission-role*' \) -print | grep -q . && ! grep -R -l 'password123' /app/dist-worker 2>/dev/null"
;;
google-provider-redis)
sh scripts/ci/smoke-provider-redis-image.sh repkey-google-provider-redis:ci
;;
provider-sandbox)
docker run --rm --entrypoint sh repkey-provider-sandbox:ci -c 'set -eu; node provider-sandbox.mjs --kind=gbp --port=3000 >/tmp/provider-sandbox.log 2>&1 & child=$!; trap '\''kill "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true'\'' EXIT; attempt=0; until node -e "fetch('\''http://127.0.0.1:3000/__control/health'\'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; do attempt=$((attempt+1)); if [ "$attempt" -ge 30 ]; then cat /tmp/provider-sandbox.log; exit 1; fi; sleep 0.2; done'
test "$(docker image inspect repkey-provider-sandbox:ci --format '{{.Config.User}}')" = "node"
test "$(docker image inspect repkey-provider-sandbox:ci --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$GITHUB_SHA"
docker run --rm --entrypoint test repkey-provider-sandbox:ci ! -e /app/node_modules
docker run --rm --entrypoint test repkey-provider-sandbox:ci ! -e /usr/local/bin/npm
;;
perf-runner)
docker run --rm --entrypoint node repkey-perf-runner:ci dist-perf-runner/load-test.js --list
test "$(docker image inspect repkey-perf-runner:ci --format '{{.Config.User}}')" = "node"
test "$(docker image inspect repkey-perf-runner:ci --format '{{len .Config.Cmd}} {{join .Config.Cmd " "}}')" = "2 sleep infinity"
test "$(docker image inspect repkey-perf-runner:ci --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$GITHUB_SHA"
docker run --rm --entrypoint test repkey-perf-runner:ci ! -e /app/src
docker run --rm --entrypoint test repkey-perf-runner:ci ! -e /app/node_modules/tsx
docker run --rm --entrypoint test repkey-perf-runner:ci ! -e /usr/local/bin/npm
docker run --rm --entrypoint test repkey-perf-runner:ci -e /app/node_modules/pg
docker run --rm --entrypoint node repkey-perf-runner:ci -e "const p=require('/app/package.json');process.exit('scripts' in p?1:0)"
for entry in load-test.js seed-scale.js seed-fleet.js write-scale-evidence.js; do
docker run --rm --entrypoint test repkey-perf-runner:ci -e "/app/dist-perf-runner/$entry"
done
;;
*)
echo "::error::No smoke contract for matrix image $IMAGE_NAME"
exit 1
;;
esac
{
echo "### Governed image digest — $IMAGE_NAME"
echo "\`$IMAGE_TAG@$(docker image inspect "$IMAGE_TAG" --format '{{.Id}}')\`"
} >> "$GITHUB_STEP_SUMMARY"
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
# BQC-7.7: every built image gets an SPDX JSON inventory. Each bounded
# group stages its files under one unique artifact; the `docker`
# aggregate job still proves and publishes the exact five-file set.
- name: Install pinned Syft
id: syft
uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
syft-version: v1.42.3
- name: Generate grouped image SBOMs
env:
IMAGE_GROUP_JSON: ${{ matrix.images }}
SYFT_CMD: ${{ steps.syft.outputs.cmd }}
run: |
set -euo pipefail
while IFS= read -r image; do
name=$(jq -r '.name' <<<"$image")
tag=$(jq -r '.tag' <<<"$image")
"$SYFT_CMD" scan "$tag" -o "spdx-json=sbom-${name}.spdx.json"
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
- name: Stage grouped image SBOMs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-image-group-${{ matrix.group }}-spdx
path: sbom-*.spdx.json
if-no-files-found: error
retention-days: 1
# BQC-7.7: hard fail on HIGH+ findings. Scanning each generated SBOM
# avoids re-cataloguing the image, and the explicit config keeps every
# reviewed exception in the fail-closed gate.
#
# Own the database cache and rotate its exact key once per UTC day; no
# restore prefix may fall back to a stale prior-day DB.
- name: Resolve Grype DB cache date
id: grype-db-date
run: echo "utc=$(date -u +%F)" >> "$GITHUB_OUTPUT"
- name: Restore daily Grype vulnerability DB
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/grype/db
key: grype-db-v0.116.1-${{ steps.grype-db-date.outputs.utc }}
- name: Install pinned Grype
id: grype
uses: anchore/scan-action/download-grype@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2
with:
grype-version: v0.116.1
# The gate prints WHAT it found, not just that it found something.
#
# This step used to emit SARIF to a file that nothing uploaded and nothing
# read, so a failure said only `discovered vulnerabilities at or above the
# severity threshold` — no package, no CVE, no fixed-in version. On
# 2026-09-06 that cost a day: the gate went red on a vulnerability-database
# rollover, and the finding could not be named from CI at all, so the
# documented response (bump the base, or write an exception with an owner
# and a reason) could not be chosen on evidence. grype takes repeated `-o`,
# so the table costs one line and the SARIF is retained as an artifact for
# the runs where the table is not enough.
- name: Vulnerability scan grouped images (grype)
env:
GRYPE_CMD: ${{ steps.grype.outputs.cmd }}
IMAGE_GROUP_JSON: ${{ matrix.images }}
run: |
set -euo pipefail
while IFS= read -r image; do
name=$(jq -r '.name' <<<"$image")
"$GRYPE_CMD" \
-v \
-o "sarif=grype-${name}.sarif" \
-o table \
--fail-on high \
-c .grype.yaml \
"sbom:sbom-${name}.spdx.json"
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
- name: Upload vulnerability scan reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: grype-scan-${{ matrix.group }}
path: grype-*.sarif
if-no-files-found: warn
# Each group pushes its scanned bytes under a unique run/attempt staging
# tag. The source-SHA tags are created only by the aggregate after both
# groups pass, preventing a failed row from stranding a partial set.
- name: Authenticate production image stager
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
GHCR_TOKEN: ${{ github.token }}
run: echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Stage scanned production images
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
IMAGE_GROUP_JSON: ${{ matrix.images }}
run: |
set -euo pipefail
mkdir -p staged-image-digests
owner=$(tr '[:upper:]' '[:lower:]' <<<"$GITHUB_REPOSITORY_OWNER")
while IFS= read -r image; do
[[ "$(jq -r '.publish' <<<"$image")" == "true" ]] || continue
IMAGE_NAME=$(jq -r '.name' <<<"$image")
IMAGE_TAG=$(jq -r '.tag' <<<"$image")
repository="ghcr.io/${owner}/repkey-${IMAGE_NAME}"
staged_reference="${repository}:ci-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
if docker buildx imagetools inspect "$staged_reference" >/dev/null 2>&1; then
echo "::error::run-scoped staging tag already exists: $staged_reference"
exit 1
fi
test "$(docker image inspect "$IMAGE_TAG" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$GITHUB_SHA"
docker tag "$IMAGE_TAG" "$staged_reference"
docker push "$staged_reference"
digest=$(docker buildx imagetools inspect "$staged_reference" --format '{{json .Manifest.Digest}}' | tr -d '"')
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
jq -S -n \
--arg image "$IMAGE_NAME" \
--arg repository "$repository" \
--arg stagedReference "$staged_reference" \
--arg digest "$digest" \
--arg sourceRevision "$GITHUB_SHA" \
'{image:$image,repository:$repository,stagedReference:$stagedReference,digest:$digest,sourceRevision:$sourceRevision}' \
> "staged-image-digests/${IMAGE_NAME}.json"
done < <(jq -c '.[]' <<<"$IMAGE_GROUP_JSON")
- name: Stage scanned image descriptors
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ci-image-digests-${{ matrix.group }}-${{ github.sha }}
path: staged-image-digests/*.json
if-no-files-found: error
retention-days: 90
# Keep the protected `docker` context stable while two measured image groups
# fan out above. The final artifacts are assembled here because upload-artifact
# v4+ forbids concurrent jobs mutating one shared artifact.
docker:
needs: [docker-images]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10 # longest green run ~1 min
permissions:
contents: read
packages: write
steps:
- name: Require every image matrix entry to succeed
if: needs.docker-images.result != 'success'
run: exit 1
- name: Download staged image SBOMs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: sbom-image-group-*-spdx
path: image-sboms
merge-multiple: true
- name: Require all five image SBOMs
run: test "$(find image-sboms -maxdepth 1 -type f -name 'sbom-*.spdx.json' | wc -l)" -eq 5
- name: Upload image SBOMs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sbom-images-spdx
path: image-sboms/sbom-*.spdx.json
if-no-files-found: error
retention-days: 30
- name: Download staged production image descriptors
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: ci-image-digests-*-${{ github.sha }}
path: staged-image-digests
merge-multiple: true
- name: Validate complete staged production image set
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
set -euo pipefail
test "$(find staged-image-digests -maxdepth 1 -type f -name '*.json' | wc -l)" -eq 3
jq -sS \
--arg revision "$GITHUB_SHA" \
--arg runId "$GITHUB_RUN_ID" \
--arg runAttempt "$GITHUB_RUN_ATTEMPT" \
'sort_by(.image) as $entries
| ["google-provider-redis","web","worker"] as $expected
| ($entries | map(.image)) as $observed
| if $observed != $expected then error("staged image set is incomplete or unexpected") else . end
| if any($entries[];
.sourceRevision != $revision
or (.digest | test("^sha256:[0-9a-f]{64}$") | not)
or .repository != ("ghcr.io/kodes-agency/repkey-" + .image)
or .stagedReference != (.repository + ":ci-run-" + $runId + "-" + $runAttempt)
)
then error("staged image repository, digest, source revision, or run reference is invalid")
else $entries
end' \
staged-image-digests/*.json > validated-image-digests.json
- name: Authenticate production image promoter
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
GHCR_TOKEN: ${{ github.token }}
run: echo "$GHCR_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Promote complete production image set
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
set -euo pipefail
# Preflight every staged digest and every existing immutable tag
# before creating any source-SHA tag.
while IFS= read -r image; do
repository=$(jq -r '.repository' <<<"$image")
staged_reference=$(jq -r '.stagedReference' <<<"$image")
expected_digest=$(jq -r '.digest' <<<"$image")
staged_digest=$(docker buildx imagetools inspect "$staged_reference" --format '{{json .Manifest.Digest}}' | tr -d '"')
test "$staged_digest" = "$expected_digest"
final_reference="${repository}:${GITHUB_SHA}"
if docker buildx imagetools inspect "$final_reference" >/dev/null 2>&1; then
final_digest=$(docker buildx imagetools inspect "$final_reference" --format '{{json .Manifest.Digest}}' | tr -d '"')
if [[ "$final_digest" != "$expected_digest" ]]; then
echo "::error::immutable image tag has a conflicting digest: $final_reference"
exit 1
fi
fi
done < <(jq -c '.[]' validated-image-digests.json)
while IFS= read -r image; do
repository=$(jq -r '.repository' <<<"$image")
expected_digest=$(jq -r '.digest' <<<"$image")
final_reference="${repository}:${GITHUB_SHA}"
if ! docker buildx imagetools inspect "$final_reference" >/dev/null 2>&1; then
docker buildx imagetools create \
--prefer-index=false \
--tag "$final_reference" \
"${repository}@${expected_digest}"
fi
done < <(jq -c '.[]' validated-image-digests.json)
# The release map is written only after all three final tags resolve
# to the staged digests.
while IFS= read -r image; do
repository=$(jq -r '.repository' <<<"$image")
expected_digest=$(jq -r '.digest' <<<"$image")
final_reference="${repository}:${GITHUB_SHA}"
final_digest=$(docker buildx imagetools inspect "$final_reference" --format '{{json .Manifest.Digest}}' | tr -d '"')
test "$final_digest" = "$expected_digest"
done < <(jq -c '.[]' validated-image-digests.json)
jq -S \
--arg revision "$GITHUB_SHA" \
--arg repository "$GITHUB_REPOSITORY" \
'{
version:"repkey-ci-image-digest-map-1",
sourceRevision:$revision,
source:{
repository:$repository,
ref:"refs/heads/main",
workflow:".github/workflows/ci.yml",
runId:env.GITHUB_RUN_ID,
runAttempt:(env.GITHUB_RUN_ATTEMPT | tonumber)
},
images:(map({key:.image,value:{repository:.repository,digest:.digest,sourceRevision:.sourceRevision}}) | from_entries)
}' \
validated-image-digests.json > ci-image-digest-map.json
- name: Upload production image digest map
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ci-image-digest-map-${{ github.sha }}
path: ci-image-digest-map.json
if-no-files-found: error
retention-days: 90
# BQC-7.7: secret scanning incl. full git history (gitleaks). Separate job:
# runs in parallel with check (no pnpm install needed) and its failure is a
# distinct required check. gitleaks-action is deliberately NOT used (org
# license requirement) — the official image is pinned by digest instead.
secrets:
runs-on: ubuntu-latest
timeout-minutes: 10 # longest green run ~1 min
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Full history — gitleaks scans every commit, not just the PR diff.
fetch-depth: 0
- name: Gitleaks
# BQC-7.7: PRs scan the PR range (sub-second-to-minutes); pushes to
# main scan ALL refs — the full-history net (measured 8m25s for 1960
# commits / 7.7 GB locally, acceptable on the merge path; ~2 min per
# PR is not achievable with this history size, hence the split — see
# docs/operations/security-ci-policy.md).
# --config: committed .gitleaks.toml (performance allowlist for the
# committed binary caches — see its header; NOT finding suppression).
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
OPTS="origin/${{ github.base_ref }}..HEAD"
else
OPTS="--all"
fi
echo "gitleaks log-opts: $OPTS"
docker run --rm -v "$PWD:/repo" \
zricethezav/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
git /repo --config /repo/.gitleaks.toml --redact --log-opts="$OPTS"
storybook-test:
runs-on: ubuntu-latest
timeout-minutes: 20 # longest green run ~14 min
# Component-test coverage: renders every story in headless Chromium and
# runs its play function plus axe — the hard gate for BQR-5.4 a11y.
#
# This used to run @storybook/test-runner against a live Storybook dev
# server. That runner is a Storybook 8/9 tool: under Storybook 10 its jest
# runtime cannot load the config at all (`throwHooksUnsupported`), so all
# 93 suites failed at setup and 0 tests ran. The vitest project is the
# supported path and needs no dev server.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# Chromium was downloaded from scratch in every job that needs it. Key the
# cache on the resolved @playwright/test version so a bump invalidates it.
- name: Resolve Playwright version
id: pw
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium
- name: Install Playwright browser
if: steps.pw-cache.outputs.cache-hit != 'true'
timeout-minutes: 10
run: pnpm exec playwright install chromium
# Bounded, cached and self-naming: see the e2e job's steps of the same names.
- name: Week of the Ubuntu package cache
id: apt-week
run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Restore Ubuntu packages for Playwright
id: apt-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/playwright-apt
key: playwright-apt-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium_${{ steps.apt-week.outputs.week }}
restore-keys: playwright-apt-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium_
- name: Install Playwright system packages
timeout-minutes: 10
run: PLAYWRIGHT_DEPS_CACHE_DIR="$HOME/.cache/playwright-apt" sh scripts/ci/install-playwright-deps.sh chromium
# Saved straight after the install, so a later test failure still keeps
# this week's packages. Once main has saved a week's, pull requests hit
# it exactly and save nothing; before that, a rerun of a failed shard
# finds what a sibling shard downloaded.
- name: Save Ubuntu packages for Playwright
if: steps.apt-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/playwright-apt
key: ${{ steps.apt-cache.outputs.cache-primary-key }}
# The stall guard fails the run about a minute after a story page goes
# quiet, and names the story (scripts/ci/storybook-stall-guard.ts). A
# frozen page runs no JavaScript, so Vitest's own timeouts cannot fire
# there, and without the guard the job waited for timeout-minutes with a
# log that named no story. Naming any reporter drops Vitest's defaults,
# so the two it uses on GitHub Actions are named as well.
- name: Run storybook tests
run: >-
pnpm test:storybook
--reporter=default
--reporter=github-actions
--reporter=./scripts/ci/storybook-stall-guard.ts
# Sharded because e2e WAS the entire CI critical path: 9m05s of a 9m05s wall,
# 283s of it the `critical` project at one worker and 148s booting the Compose
# stack. Measured per file from the uploaded Playwright JSON, not estimated.
#
# WHY SHARDS AND NOT MORE WORKERS. The `critical` project pins `workers: 1`
# because its journeys toggle a shared kill switch and restore it, so a second
# browser against the same stack observes the bounded window and fails
# (playwright.config.ts). Every shard below boots its OWN Compose stack and
# seeds it from empty, so that constraint holds inside each shard while the
# shards run concurrently. Raising `workers` would have broken it; sharding
# does not, and that was checked first rather than last.
#
# PROVEN BEFORE LANDING. Each shard was run locally against a freshly reseeded
# stack and passed alone: 23 / 21 / 12 / 17 tests. Shards are file-atomic by
# construction — see the `fullyParallel: false` note in playwright.config.ts.
#
# COST. Four Compose stacks instead of one: roughly 4x the runner minutes for
# this job, bought to remove ~4 minutes of wall from every push. Stack boot
# (148s) is now the floor and the next thing worth attacking.
e2e-shard:
name: e2e shard (${{ matrix.shard }}/4)
runs-on: ubuntu-latest
# BQR-5.1: critical Playwright project is a hard gate (auth + properties shell).
# BQC-6.7: the full suite is a hard gate too — deterministic and green
# (fake mail outbox, hydration-safe specs, F-PEOPLE fix); required for
# release-candidate promotion.
timeout-minutes: 25
strategy:
# Every shard reports its own failures; one red shard must not hide the
# others, exactly as test-unit does.
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
env:
CI: '1'
E2E_BASE_URL: http://127.0.0.1:3000
E2E_LOCKED_BASE_URL: http://127.0.0.1:3001
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
# version intentionally unset: package.json packageManager (pnpm@10.6.5) is the single source
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# The release browser matrix is cached on the resolved Playwright version;
# a dependency bump therefore replaces Chromium, Firefox, and WebKit as
# one coherent test runtime.
- name: Resolve Playwright version
id: pw
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: pw-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium-firefox-webkit
# The cache key names the BROWSER SET, not just the Playwright version.
# Three jobs share ~/.cache/ms-playwright: two need chromium alone and
# this one also needs firefox and webkit for the compatibility gate. On
# a shared key whichever job populated the cache first won, so a hit
# here ran `install-deps` against a chromium-only directory and every
# Firefox and WebKit test failed with "Executable doesn't exist".
- name: Install Playwright release browsers
if: steps.pw-cache.outputs.cache-hit != 'true'
timeout-minutes: 10
run: pnpm exec playwright install chromium firefox webkit
# The browsers' system packages (~119 MB of .deb files) came from
# Ubuntu's mirrors on every run. On 2026-10-22 the mirror GitHub's
# runners prefer trickled, then archive.ubuntu.com did too, and this
# install (~30 s) took 17-25 min: 12 shards hit timeout-minutes, some
# inside a later step that was not at fault. The packages are now cached
# per ISO week, so a warm run downloads only what Ubuntu updated since
# (apt checks every cached file against the signed package lists). The
# script gives any download a deadline, moves to the next mirror once,
# and names the phase and mirror when it gives up
# (scripts/ci/install-playwright-deps.sh). timeout-minutes here only
# backstops the script itself.
- name: Week of the Ubuntu package cache
id: apt-week
run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Restore Ubuntu packages for Playwright
id: apt-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/playwright-apt
key: playwright-apt-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium-firefox-webkit_${{ steps.apt-week.outputs.week }}
restore-keys: playwright-apt-${{ runner.os }}-${{ steps.pw.outputs.version }}-chromium-firefox-webkit_
- name: Install Playwright system packages
timeout-minutes: 10
run: PLAYWRIGHT_DEPS_CACHE_DIR="$HOME/.cache/playwright-apt" sh scripts/ci/install-playwright-deps.sh chromium firefox webkit
# Saved straight after the install, so a later test failure still keeps
# this week's packages. Once main has saved a week's, pull requests hit
# it exactly and save nothing; before that, a rerun of a failed shard
# finds what a sibling shard downloaded.
- name: Save Ubuntu packages for Playwright
if: steps.apt-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/playwright-apt
key: ${{ steps.apt-cache.outputs.cache-primary-key }}
- name: Start local Compose application stack
run: pnpm e2e:stack:up
- name: Run critical e2e (hard gate — BQR-5.1)
id: e2e_critical
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/e2e-critical-report.json
run: pnpm test:e2e --project=critical --shard=${{ matrix.shard }}/4 --max-failures=1 ${{ github.event_name == 'push' && '--fail-on-flaky-tests' || '' }}
# BQC-6.4: stop scheduling critical journeys after the first failure, but
# preserve that attempt's trace even when the configured retry succeeds.
# This upload runs before the full suite clears Playwright's outputDir.
- name: Upload critical e2e first-failure artifacts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-artifacts-critical-${{ matrix.shard }}
path: test-results/playwright/**
if-no-files-found: ignore
retention-days: 7
# `full` (34s) and the compatibility gate (49s) are too small to justify
# their own Compose stack, so both ride the lightest critical shard
# instead of being sharded themselves. Measured critical seconds per
# shard: 1=66.6, 2=84.9, 3=14.4, 4=111.8. Loading both onto shard 3 puts
# it at 97.4s, still under shard 4's 111.8s, so neither becomes the long
# pole. Shard 4 — nine `workflows/` files — is now this job's floor apart
# from stack boot.
- name: Run full e2e suite (hard gate — BQC-6.7)
id: e2e_full
if: matrix.shard == 3
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/e2e-full-report.json
run: pnpm test:e2e --project=full ${{ github.event_name == 'push' && '--fail-on-flaky-tests' || '' }}
# BQC-6.7: the full suite is a hard gate — its failure fails the job, so
# `if: failure()` would also fire; gating on the step outcome stays more
# precise (a skipped full run after a critical-gate failure does not
# upload here — the critical upload above owns those artifacts).
- name: Upload full e2e failure artifacts
if: steps.e2e_full.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-artifacts-full-${{ matrix.shard }}
path: test-results/playwright/**
if-no-files-found: ignore
retention-days: 7
- name: Run bounded browser and mobile compatibility gate
id: e2e_compatibility
if: matrix.shard == 3
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results/e2e-compatibility-report.json
run: pnpm test:e2e:compatibility ${{ github.event_name == 'push' && '--fail-on-flaky-tests' || '' }}
- name: Upload exact browser reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-browser-reports-${{ matrix.shard }}
path: |
test-results/e2e-critical-report.json
test-results/e2e-full-report.json
test-results/e2e-compatibility-report.json
if-no-files-found: ignore
retention-days: 14
- name: Upload compatibility failure artifacts
if: steps.e2e_compatibility.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-artifacts-compatibility-${{ matrix.shard }}
path: test-results/playwright/**
if-no-files-found: ignore
retention-days: 7
- name: Collect local Compose diagnostics
if: always()
run: pnpm e2e:stack:logs
- name: Upload local stack evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-local-stack-evidence-${{ matrix.shard }}
path: test-results/local-stack/**
if-no-files-found: ignore
retention-days: 7
- name: Tear down local Compose application stack
if: always()
run: pnpm e2e:stack:down
# Keeps the required status check named exactly `e2e` after the matrix split.
# Renaming the job to `e2e-shard` without this would leave branch protection
# waiting forever for a check that no longer exists — the same failure mode as
# the `audit` check that sat red for eight commits because its config named a
# deleted file. Same house pattern as `docker` over `docker-images`.
e2e:
needs: [e2e-shard]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5 # longest green run ~1 min
steps:
- name: Require every e2e shard to succeed
if: needs.e2e-shard.result != 'success'
run: exit 1