Skip to content

Make auth assertions single-use. #20482

Make auth assertions single-use.

Make auth assertions single-use. #20482

Workflow file for this run

# This workflow will build the project on pull requests with tests
# Uses:
# OS: ubuntu-latest
# Go: go 1.x
name: 👷🛠️ PR Builder
# TEMPORARY WORKAROUND for runner saturation (see #4268): on pull requests, the
# builder only runs once the `trigger-pr-builder` label is present. Early pushes
# iterate against the review bots without consuming runners, and the label
# (added when the PR is ready) turns CI on for that push and every subsequent
# one. Merge queue runs are always on. Remove this gate (restore unconditional
# pull_request runs) once queue pressure is resolved.
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
merge_group:
workflow_dispatch:
# Avoid running multiple PR builders for the same PR on subsequent pushes. GitHub Actions'
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
GOFLAGS: "-mod=readonly"
PRODUCT_NAME: "ThunderID"
PRODUCT_NAME_LOWER: "thunderid"
NODE_VERSION: "lts/*"
jobs:
# Single job for the fast gate checks (security audit, dependency review, and
# change detection for docs/PowerShell) so they occupy one runner slot instead
# of four. Runner concurrency is shared across all PRs, so fewer jobs per run
# directly reduces queue time on busy periods.
#
# This is also the root of the job graph: every other job depends on it, so a
# run requests one runner up front instead of eight competing for slots, and
# nothing expensive starts until these ~20s of checks pass.
preflight:
name: 🛡️ Preflight Checks
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
outputs:
docs-changed: ${{ steps.filter.outputs.docs }}
powershell-changed: ${{ steps.filter.outputs.powershell }}
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# Diff-based steps need a base/head pair from the pull_request or
# merge_group payload, which a manual dispatch does not carry.
- name: 🔍 Detect Docs and PowerShell Changes
if: ${{ github.event_name != 'workflow_dispatch' }}
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3
id: filter
with:
base: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event.pull_request.base.sha }}
ref: ${{ github.event_name == 'merge_group' && github.sha || github.event.pull_request.head.sha }}
filters: |
docs:
- 'docs/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
powershell:
- '**.ps1'
- '.github/workflows/windows-powershell-validation.yml'
# This job only audits the lockfile, so it must not cache a pnpm store:
# an empty store published here is what every later job restored.
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
cache-store: 'false'
- name: 🔍 Run pnpm audit
run: |
echo "🔍 Running pnpm audit for all workspace dependencies..."
# CVE ignores are managed via auditConfig.ignoreCves in pnpm-workspace.yaml
# Remove `--ignore-registry-errors` once pnpm 11 is generally available. Tracker: https://github.com/pnpm/pnpm/issues/11265
pnpm audit --ignore-registry-errors --audit-level=high
- name: 🔎 Dependency Review
if: ${{ github.event_name != 'workflow_dispatch' }}
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4
with:
fail-on-severity: high
deny-licenses: GPL-3.0-only,GPL-3.0-or-later,AGPL-3.0-only,AGPL-3.0-or-later
comment-summary-in-pr: always
base-ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event.pull_request.base.sha }}
head-ref: ${{ github.event_name == 'merge_group' && github.sha || github.event.pull_request.head.sha }}
codecov-merge-queue-status:
name: 📊 Codecov Merge Queue Status
# Codecov does not reliably process merge queue (merge_group) commits, leaving the
# required codecov/patch status stuck at "Expected" and timing the queue entry out.
# Patch coverage is already enforced on the pull request before it can enter the
# queue, and the queue commit carries the same diff rebased onto main, so this job
# marks codecov/patch as passed on the queue commit instead of re-running Codecov.
# Tracker: https://github.com/codecov/codecov-action/issues/1796
if: github.event_name == 'merge_group'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
statuses: write
steps:
- name: 📊 Mark codecov/patch as passed on the merge queue commit
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api "repos/${{ github.repository }}/statuses/${{ github.sha }}" \
-f state=success \
-f context=codecov/patch \
-f description="Patch coverage was enforced on the pull request"
verify-mocks:
name: 🔍 Verify Mock Files
needs: [preflight]
if: ${{ (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: ⚙️ Set up Go Environment
uses: ./.github/actions/setup-go
- name: 🔧 Install Mockery
run: make install-mockery
- name: 🏭 Generate Mocks
run: make mockery
- name: 🔎 Check for Outdated Mocks
run: |
if [ -n "$(git status --porcelain --untracked-files=all -- backend/tests/mocks backend/internal)" ]; then
echo "❌ Mock files are out of sync with interface definitions!"
echo ""
echo "The following mock files need to be regenerated:"
git status --porcelain --untracked-files=all -- backend/tests/mocks backend/internal | awk '{print $2}'
echo ""
echo "To fix this:"
echo " 1. Run: make mockery"
echo " 2. Commit the updated mock files"
echo " 3. Push your changes"
echo ""
echo "This ensures mock implementations stay synchronized with their interfaces."
exit 1
fi
echo "✅ All mock files are up to date"
lint:
name: 🧹 Lint Code
needs: [preflight]
if: ${{ (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# We need the full history so that Turbo's --affected can diff against the PR or merge-group base commit.
fetch-depth: 0
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
- name: ⚙️ Set up Go Environment
uses: ./.github/actions/setup-go
- name: 📦 Prepare golangci-lint Locally
run: make golangci-lint
- name: 🔍 Run Backend Linter
run: make lint_backend
# The frontend build is needed because linting is type-aware: apps resolve
# @thunderid/* package types from their built dist output. The Turbo cache
# makes it a near no-op when nothing relevant changed.
- name: 🗂️ Set up Turbo Cache
uses: ./.github/actions/setup-turbo-cache
- name: 🧩 Install Dependencies & Build Frontend
run: |
make build_frontend
- name: 🔍 Run Frontend Linter
run: |
pnpm turbo run lint --affected --filter=!./tests/**
env:
TURBO_SCM_BASE: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.event.pull_request.base.sha }}
- name: 🎨 Check Frontend Formatting
run: |
pnpm format:check
build:
name: 🛠️ Build Product
needs: [preflight]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
id-token: write
contents: read
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: ⚙️ Set up Go Environment
uses: ./.github/actions/setup-go
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
- name: 🗄️ Cache Go Modules
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: cache-go-modules
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-modules-
- name: 📦 Install Backend Dependencies
run: |
cd backend
go mod download
cd ../tests/integration
go mod download
- name: 🗂️ Set up Turbo Cache
uses: ./.github/actions/setup-turbo-cache
- name: 📦 Install Frontend Dependencies
run: |
pnpm install --frozen-lockfile
- name: 🧹 Clean Previous Builds
run: |
set -e
make clean
- name: 🔨 Build Frontend
env:
# Activates the Codecov bundle-analysis plugin in the gate/console Vite
# builds, which uploads bundle stats to Codecov via GitHub OIDC.
CODECOV_BUNDLE_UPLOAD: "true"
run: |
make build_frontend
- name: 🔨 Build Backend with Coverage Instrumentation
run: |
set -e
export LOG_LEVEL=debug
ENABLE_COVERAGE=true ./build.sh build_backend $(go env GOOS) $(go env GOARCH)
# Find the built distribution
DIST_PATH=$(find target/dist -name "$PRODUCT_NAME_LOWER-*.zip" | head -1)
echo "Built distribution: $DIST_PATH"
- name: 📦 Upload Built Distribution
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: product-distribution
path: target/dist/*.zip
if-no-files-found: error
test-backend-unit:
name: 🧪 Backend Unit Tests
needs: [preflight]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
id-token: write
contents: read
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: ⚙️ Set up Go Environment
uses: ./.github/actions/setup-go
- name: 🗄️ Cache Go Modules
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-modules-
- name: 📦 Install Backend Dependencies
run: |
cd backend
go mod download
- name: 🧪 Run Backend Unit Tests
run: |
set -e
./build.sh test_unit $(go env GOOS) $(go env GOARCH)
- name: 📊 Upload Unit Test Coverage Report to Codecov
# Skipped on merge queue commits: Codecov cannot process them, and the
# codecov-merge-queue-status job stamps the required status instead.
if: github.event_name != 'merge_group'
uses: codecov/codecov-action@v5
timeout-minutes: 5
continue-on-error: true
with:
use_oidc: true
files: backend/coverage_unit.out
disable_search: true
flags: backend-unit
name: Backend Unit Tests
fail_ci_if_error: false
- name: 📦 Archive Unit Coverage Report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: unit-coverage-report
path: backend/coverage_unit.out
if-no-files-found: error
test-frontend-packages:
name: 🧪 Frontend Packages Tests
needs: [preflight]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
# Checkout, turbo cache and artifact upload need nothing beyond read access; the
# artifact action authenticates with its own token rather than the GITHUB_TOKEN.
permissions:
contents: read
container:
image: mcr.microsoft.com/playwright:v1.60.0-noble
options: --user root
# PLAYWRIGHT_BROWSERS_PATH: the Playwright image installs browsers under /ms-playwright but
# some tooling (e.g. @vitest/browser-playwright) resolves off HOME/.cache/ms-playwright
# when the image's ENV is masked by the runner. Pinning at job level guarantees resolution.
#
# HOME=/root: GitHub Actions overrides HOME=/github/home for container jobs, but that
# directory is owned by uid 1001 (the default runner user); since we run as root, Firefox
# refuses to launch ("$HOME folder isn't owned by the current user"). Chromium and WebKit
# tolerate it; Firefox does not. Setting HOME=/root aligns ownership with the running user.
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
HOME: /root
# GitHub Actions defaults container jobs to POSIX `sh`; force bash so brace expansion,
# arrays, and `[[ ... ]]` used elsewhere in this workflow behave the same as on runners.
defaults:
run:
shell: bash
timeout-minutes: 30
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: 📦 Enable pnpm via corepack
# The Playwright image ships Node with corepack; use it instead of setup-node/setup-pnpm,
# both of which are noisy inside containers and add ~15-20s.
run: |
corepack enable
corepack prepare pnpm@11.21.0 --activate
pnpm --version
- name: 📦 Install Frontend Dependencies
run: |
pnpm install --frozen-lockfile
- name: 🗂️ Set up Turbo Cache
uses: ./.github/actions/setup-turbo-cache
- name: 🔨 Build Frontend Packages
working-directory: frontend
run: |
pnpm build:packages
- name: 🧪 Run Tests for Frontend Packages with Coverage
working-directory: frontend
run: |
pnpm test:packages:coverage
# Each package writes its own coverage/lcov.info with repo-relative paths, so the
# whole set is uploaded under one flag rather than one flag per package.
- name: 📤 Upload packages coverage artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: packages-coverage
path: frontend/packages/*/coverage/lcov.info
if-no-files-found: error
test-frontend-gate-app:
name: 🧪 Gate App Tests with Coverage
needs: [preflight]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
- name: 📦 Install Frontend Dependencies
run: |
pnpm install --frozen-lockfile
- name: 🗂️ Set up Turbo Cache
uses: ./.github/actions/setup-turbo-cache
- name: 🔨 Build Frontend
run: |
pnpm build:frontend
- name: 🧪 Run Gate Tests with Coverage
run: |
cd frontend/apps/gate
pnpm test:coverage
# Strip branch data (BRDA/BRF/BRH) from frontend LCOV reports before uploading.
# React Compiler (babel-plugin-react-compiler) generates phantom branch points in compiled
# output that inflate the partial-line count in Codecov's coverage calculation.
# This is a known upstream issue: https://github.com/facebook/react/issues/32950
# Line and function coverage remain unaffected and accurately reflect test quality.
- name: 🧹 Strip branch data from gate LCOV report
run: |
if [ -f "frontend/apps/gate/coverage/lcov.info" ]; then
sed -i '/^BRDA:/d;/^BRF:/d;/^BRH:/d' "frontend/apps/gate/coverage/lcov.info"
fi
- name: 📦 Upload gate coverage artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: gate-coverage
path: frontend/apps/gate/coverage/lcov.info
if-no-files-found: error
# The console suite is the longest job in the pipeline (~12 min of vitest in a
# single run), so it is split across shards that execute concurrently. Each
# shard reports coverage for the whole app with only its own tests counted;
# test-frontend-console-app below merges the partial reports back into one
# LCOV file, so coverage consumers still see a single console-coverage
# artifact.
test-frontend-console-app-shards:
name: 🧪 Console App Tests (shard ${{ matrix.shard }})
needs: [preflight]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
# Report every failing shard, not just the first one, so a single run
# surfaces all breakages.
fail-fast: false
matrix:
shard: [1, 2, 3]
env:
SHARD_COUNT: 3
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
- name: 📦 Install Frontend Dependencies
run: |
pnpm install --frozen-lockfile
- name: 🗂️ Set up Turbo Cache
uses: ./.github/actions/setup-turbo-cache
- name: 🔨 Build Frontend
run: |
pnpm build:frontend
- name: 🧪 Run Console Tests with Coverage
working-directory: frontend/apps/console
run: |
pnpm exec vitest run --coverage --shard=${{ matrix.shard }}/${{ env.SHARD_COUNT }}
# Strip branch data (BRDA/BRF/BRH) from frontend LCOV reports before uploading.
# React Compiler (babel-plugin-react-compiler) generates phantom branch points in compiled
# output that inflate the partial-line count in Codecov's coverage calculation.
# This is a known upstream issue: https://github.com/facebook/react/issues/32950
# Line and function coverage remain unaffected and accurately reflect test quality.
- name: 🧹 Strip branch data from console LCOV report
run: |
if [ -f "frontend/apps/console/coverage/lcov.info" ]; then
sed -i '/^BRDA:/d;/^BRF:/d;/^BRH:/d' "frontend/apps/console/coverage/lcov.info"
fi
# Named without "coverage" so the patch coverage gate, which downloads
# every "*coverage*" artifact, only ever sees the merged report.
- name: 📦 Upload console shard LCOV report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: console-lcov-shard-${{ matrix.shard }}
path: frontend/apps/console/coverage/lcov.info
if-no-files-found: error
# Keeps the console suite reporting as one check and one coverage artifact
# regardless of how many shards run. Uses always() with an explicit result
# check so a failing shard fails this job too: a skipped required check counts
# as passing for branch protection, which would let a red shard through.
test-frontend-console-app:
name: 🧪 Console App Tests with Coverage
needs: [test-frontend-console-app-shards]
if: ${{ always() && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group') }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: ✅ Check shard results
env:
SHARDS_RESULT: ${{ needs.test-frontend-console-app-shards.result }}
run: |
if [ "$SHARDS_RESULT" != "success" ]; then
echo "❌ Console test shards did not pass (result: $SHARDS_RESULT)"
exit 1
fi
echo "✅ All console test shards passed"
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: 📥 Download console shard LCOV reports
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: console-lcov-shard-*
path: console-shards
- name: 🧩 Merge console shard LCOV reports
uses: ./.github/actions/merge-lcov
with:
reports-dir: console-shards
output-file: frontend/apps/console/coverage/lcov.info
- name: 📦 Upload console coverage artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: console-coverage
path: frontend/apps/console/coverage/lcov.info
if-no-files-found: error
upload-frontend-coverage:
name: 📊 Upload Frontend Coverage
needs: [test-frontend-gate-app, test-frontend-console-app, test-frontend-packages]
if: always() && (needs.test-frontend-gate-app.result == 'success' && needs.test-frontend-console-app.result == 'success' && needs.test-frontend-packages.result == 'success')
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
id-token: write
contents: read
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: 📥 Download console coverage artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: console-coverage
path: coverage/console
- name: 📥 Download gate coverage artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: gate-coverage
path: coverage/gate
- name: 📥 Download packages coverage artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: packages-coverage
path: coverage/packages
- name: 📊 Upload `@thunderid/console` Unit Test Coverage Report to Codecov
if: github.event_name != 'merge_group'
uses: codecov/codecov-action@v5
timeout-minutes: 5
continue-on-error: true
with:
use_oidc: true
files: coverage/console/lcov.info
disable_search: true
flags: frontend-apps-console-unit
name: Frontend Console App Unit Tests
fail_ci_if_error: false
- name: 📊 Upload `@thunderid/gate` Unit Test Coverage Report to Codecov
if: github.event_name != 'merge_group'
uses: codecov/codecov-action@v5
timeout-minutes: 5
continue-on-error: true
with:
use_oidc: true
files: coverage/gate/lcov.info
disable_search: true
flags: frontend-apps-gate-unit
name: Frontend Gate App Unit Tests
fail_ci_if_error: false
# One report per package, so search the downloaded tree instead of naming a single
# file. Without this every package under frontend/packages/ stays unmeasured, which
# is why moving console features into packages silently dropped them from coverage.
- name: 📊 Upload Frontend Packages Unit Test Coverage Report to Codecov
if: github.event_name != 'merge_group'
uses: codecov/codecov-action@v5
timeout-minutes: 5
continue-on-error: true
with:
use_oidc: true
directory: coverage/packages
flags: frontend-packages-unit
name: Frontend Packages Unit Tests
fail_ci_if_error: false
patch-coverage-gate:
name: 🛡️ Patch Coverage Gate
# Deterministic patch coverage check that always reports, unlike the external
# codecov/patch status which can hang at "Expected" (fork PRs, merge queue
# commits, Codecov processing stalls). The threshold and the ignored paths are
# read from codecov.yml, so this enforces the same rules as codecov/patch
# without a second copy of them here. The enforcement logic lives in
# .github/actions/patch-coverage-gate.
needs: [build, test-backend-unit, test-integration, test-frontend-console-app, test-frontend-gate-app, upload-frontend-coverage]
if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') && needs.build.result == 'success' && needs.test-backend-unit.result == 'success' && needs.test-integration.result == 'success' && needs.test-frontend-console-app.result == 'success' && needs.test-frontend-gate-app.result == 'success' && needs.upload-frontend-coverage.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
pull-requests: read
checks: read
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
persist-credentials: false
# Codecov never processes merge queue commits, so a queue entry is compared
# through the pull request it was created from: the queue commit carries
# that same diff rebased onto main. The pull request number is embedded in
# the queue ref, and its head commit already has a fully processed Codecov
# report from the pull request run. Leaving the outputs empty makes the gate
# compute patch coverage locally, which is what queue runs did before.
# On merge queue runs the commit under test is not the commit Codecov
# reported on, so the codecov/patch check run is read from the head of the
# pull request the queue entry was created from. The queue commit carries
# that same diff rebased onto main, which is the premise the
# codecov-merge-queue-status job already relies on.
- name: 🔢 Resolve the commit whose codecov/patch verdict applies
id: codecov-target
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
# refs/heads/gh-readonly-queue/<base>/pr-<number>-<base sha>
QUEUE_REF: ${{ github.event.merge_group.head_ref }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "pull_request" ]; then
echo "pr-number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
echo "head-sha=$PR_HEAD_SHA" >> "$GITHUB_OUTPUT"
exit 0
fi
PR=$(printf '%s' "$QUEUE_REF" | sed -nE 's|.*/pr-([0-9]+)-[0-9a-f]+$|\1|p')
if [ -z "$PR" ]; then
echo "⚠️ No pull request number in '$QUEUE_REF'; computing patch coverage locally."
exit 0
fi
SHA=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR}" --jq '.head.sha' 2>/dev/null || true)
if [ -z "$SHA" ]; then
echo "⚠️ Could not resolve the head commit of PR #${PR}; computing patch coverage locally."
exit 0
fi
echo "Comparing through PR #${PR} (head ${SHA})"
echo "pr-number=$PR" >> "$GITHUB_OUTPUT"
echo "head-sha=$SHA" >> "$GITHUB_OUTPUT"
- name: 🛡️ Enforce Patch Coverage
uses: ./.github/actions/patch-coverage-gate
with:
github-token: ${{ github.token }}
pr-number: ${{ steps.codecov-target.outputs.pr-number }}
head-sha: ${{ steps.codecov-target.outputs.head-sha || github.sha }}
base-ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{0}', github.base_ref) }}
build_samples:
name: 🛠️ Build Sample Apps
needs: [preflight]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: ⚙️ Set up Go Environment
uses: ./.github/actions/setup-go
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
- name: 📦 Package Sample Apps
run: |
make package_samples OS=$(go env GOOS) ARCH=$(go env GOARCH)
- name: 📦 Upload Built Sample App
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: sample-app-react-sdk
path: target/dist/sample-app-react-sdk-*.zip
# Kept separately from sample-app-react-sdk, which other workflows consume by name.
# The CLI installs this bundle for `try`, so cli-compatibility serves it with the product.
- name: 📦 Upload Wayfinder Sample
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: sample-app-wayfinder
path: target/dist/sample-app-wayfinder-*.zip
if-no-files-found: error
# Fast gate for the CLI: everything that needs neither the network nor a running server.
# Kept out of the `lint` job above because the CLI lints with its own golangci-lint, built
# against the toolchain in tools/cli/go.mod: a published prebuilt binary is compiled with an
# older Go than the CLI targets and refuses to run at all in that case.
cli-lint-unit:
name: 🔍 CLI Lint and Unit Tests
needs: [preflight]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: ⚙️ Set up Go Environment
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: tools/cli/go.mod
cache-dependency-path: tools/cli/go.sum
- name: 🧹 Lint CLI
run: make tools_lint_cli
- name: 🧪 Run CLI Unit Tests
run: make tools_test_cli
# Lane 1 of the CLI e2e strategy: does a CLI change break the CLI? Drives the npx wrapper
# against the currently published release, not the distribution built above: that pairing
# is cli-compatibility's job, below. Split from cli-lint-unit so a lint or unit failure
# reports without paying for this.
cli-e2e:
name: 🎭 CLI E2E (${{ matrix.os }})
needs: [cli-lint-unit]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# global-setup.ts builds the CLI binary that the npx wrapper resolves.
- name: ⚙️ Set up Go Environment
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: tools/cli/go.mod
cache-dependency-path: tools/cli/go.sum
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
- name: 📦 Set up CLI E2E Suite
uses: ./.github/actions/setup-cli-e2e
- name: 🔌 Verify Required Ports Are Free
uses: ./.github/actions/verify-cli-e2e-ports
- name: 🎭 Run E2E Tests
working-directory: tests/e2e-cli
run: pnpm test
- name: 📄 Upload Playwright Report on Failure
if: failure()
uses: ./.github/actions/upload-cli-e2e-report
with:
name: cli-e2e-report-${{ matrix.os }}
cli-compatibility:
name: 🔗 CLI Against This Build
needs: [preflight, build, build_samples]
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')) || github.event_name == 'merge_group' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
# global-setup builds the CLI from this checkout; the product comes from the artifacts.
- name: ⚙️ Set up Go Environment
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: tools/cli/go.mod
cache-dependency-path: tools/cli/go.sum
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
# Reusing what build-product and build-samples already produced, rather than building the
# distribution a second time.
- name: 📥 Download the Built Distribution
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: product-distribution
path: target/dist
- name: 📥 Download the Wayfinder Sample
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: sample-app-wayfinder
path: target/dist
- name: 🔍 Show What Will Be Served
run: ls -lh target/dist/
- name: 📦 Set up CLI E2E Suite
uses: ./.github/actions/setup-cli-e2e
# 8899 is the local release server this run serves target/dist through, on top of the
# suite's own fixed ports.
- name: 🔌 Verify Required Ports Are Free
uses: ./.github/actions/verify-cli-e2e-ports
with:
extra-ports: "8899"
# E2E_PRODUCT_DIST makes the suite serve target/dist with a generated manifest and point the
# CLI at it, so everything it installs was built by this pull request.
- name: 🎭 Run the CLI Against This Build
working-directory: tests/e2e-cli
env:
E2E_PRODUCT_DIST: ${{ github.workspace }}/target/dist
run: pnpm test
- name: 📄 Upload Playwright Report on Failure
if: failure()
uses: ./.github/actions/upload-cli-e2e-report
with:
name: cli-compatibility-report
test-integration:
name: 🧪 Integration Tests (${{ matrix.database }})
needs: [build, build_samples]
if: ${{ always() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
id-token: write
contents: read
strategy:
matrix:
database: [sqlite, postgres, redis]
fail-fast: false
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: dbuser
POSTGRES_PASSWORD: dbpassword
POSTGRES_DB: postgredb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:latest
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: ⚙️ Set up Go Environment
uses: ./.github/actions/setup-go
- name: 🗄️ Cache Go Modules
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: cache-go-modules
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-modules-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-modules-
- name: 📦 Install Dependencies
run: |
cd backend
go mod download
cd ../tests/integration
go mod download
- name: 📝 Configure Test Database
run: |
chmod +x tests/integration/resources/scripts/setup-test-config.sh
./tests/integration/resources/scripts/setup-test-config.sh
env:
DB_TYPE: ${{ matrix.database }}
- name: 🧪 Run Integration Tests (${{ matrix.database }})
uses: ./.github/actions/run-integration-tests
with:
database-type: ${{ matrix.database }}
coverage-enabled: true
- name: 📦 Archive Integration Coverage Report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: integration-coverage-${{ matrix.database }}
path: target/coverage_integration.out
if-no-files-found: error
- name: 📊 Upload Integration Test Coverage Report to Codecov
if: github.event_name != 'merge_group'
uses: codecov/codecov-action@v5
timeout-minutes: 5
continue-on-error: true
with:
use_oidc: true
files: target/coverage_integration.out
disable_search: true
flags: backend-integration-${{ matrix.database }}
name: Backend Integration Tests (${{ matrix.database }})
fail_ci_if_error: false
# - name: 🧩 Generate Combined Coverage Report
# run: ./build.sh merge_coverage
# - name: 📊 Upload Combined Coverage Report to Codecov
# uses: codecov/codecov-action@v4
# with:
# token: ${{ secrets.CODECOV_TOKEN }}
# files: target/coverage_combined.out
# disable_search: true
# flags: backend-combined-${{ matrix.database }}
# name: Backend-combined Coverage (${{ matrix.database }})
# fail_ci_if_error: false
backend-integration-patch-coverage:
name: 🛡️ Backend Integration Patch Coverage
# Measures only what the integration suite executed. The combined
# 🛡️ Patch Coverage Gate pools backend unit, backend integration, and both
# frontend reports into one percentage, so a changed backend line covered
# only by a unit test satisfies it. This job loads the three
# integration-coverage-* artifacts and nothing else, which is the whole
# reason it exists separately rather than as another threshold on that gate.
#
# Gated like the other checks: it needs only build and test-integration, so
# unlike the combined gate it does not wait on the frontend or unit jobs, and
# it is skipped when those two did not succeed rather than reporting on data
# it does not have.
needs: [build, test-integration]
if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') && needs.build.result == 'success' && needs.test-integration.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# The base-to-head diff needs full history; a shallow clone makes the
# merge base unreachable and every changed line look new.
fetch-depth: 0
persist-credentials: false
- name: 🛡️ Measure Backend Integration Patch Coverage
uses: ./.github/actions/backend-integration-patch-coverage
with:
base-ref: ${{ github.event_name == 'merge_group' && github.event.merge_group.base_sha || format('origin/{0}', github.base_ref) }}
# A merge_group payload carries no pull request labels, so queue runs
# apply no tag group. Thresholds still come from the base packages map,
# and only then the global default.
pr-labels: ${{ github.event_name == 'pull_request' && toJSON(github.event.pull_request.labels.*.name) || '' }}
test-integration-status:
name: ✅ Integration Tests Status
needs: [test-integration]
if: ${{ always() && (github.event_name != 'pull_request' || (contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder'))) }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check integration test results
run: |
if [[ "${{ needs.test-integration.result }}" == "failure" || "${{ needs.test-integration.result }}" == "cancelled" ]]; then
echo "❌ Integration tests failed or were cancelled"
exit 1
fi
echo "✅ Integration tests passed or were not required"
test-e2e:
name: 🎭 Playwright E2E Tests
needs: [build, build_samples]
if: ${{ always() && needs.build.result == 'success' && needs.build_samples.result == 'success' }}
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.60.0-noble
options: --user root
# HOME=/root: GitHub Actions overrides HOME=/github/home for container jobs, but that
# directory is owned by uid 1001 (the default runner user). Since we run as root, Firefox
# refuses to launch ("$HOME folder isn't owned by the current user"). Chromium and WebKit
# tolerate it; Firefox does not. Setting HOME=/root aligns ownership with the running user.
env:
HOME: /root
# GitHub Actions defaults container jobs to POSIX `sh`; force bash so brace expansion
# (e.g. `for i in {1..60}`), arrays, and `[[ ... ]]` behave the same as on runners.
defaults:
run:
shell: bash
# Provisions and runs against two servers in sequence (core, then a fresh one for @wayfinder).
timeout-minutes: 40
steps:
- name: 🔧 Install Required OS Utilities
# jq/lsof/unzip are used by the bash setup steps below. No build toolchain
# is needed: no workspace dependency compiles from source.
run: |
apt-get update
apt-get install -y --no-install-recommends jq lsof unzip
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: 📦 Enable pnpm via corepack
# The Playwright image ships Node with corepack; use it instead of setup-node/setup-pnpm,
# both of which are noisy inside containers and add ~15-20s.
run: |
corepack enable
corepack prepare pnpm@11.21.0 --activate
pnpm --version
- name: 📥 Download Built Distribution
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: product-distribution
path: target/dist/
- name: 📥 Download Built Sample App
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: sample-app-react-sdk
path: target/dist/
- name: 📂 Extract Server to E2E
run: |
mkdir -p tests/e2e/server
unzip target/dist/$PRODUCT_NAME_LOWER-*.zip -d tests/e2e/server
cd tests/e2e/server
# Find the extracted folder (name includes version and arch) and move its contents to the root of tests/e2e/server
EXTRACTED_DIR=$(find . -maxdepth 1 -type d -name "$PRODUCT_NAME_LOWER-*" | head -n 1)
if [ -n "$EXTRACTED_DIR" ]; then
mv "$EXTRACTED_DIR"/* .
rmdir "$EXTRACTED_DIR"
fi
chmod +x setup.sh start.sh
- name: 🔑 Resolve Admin Credentials
env:
SECRET_ADMIN_USERNAME: ${{ secrets.PLAYWRIGHT_ADMIN_USERNAME }}
VAR_ADMIN_USERNAME: ${{ vars.PLAYWRIGHT_ADMIN_USERNAME }}
SECRET_ADMIN_PASSWORD: ${{ secrets.PLAYWRIGHT_ADMIN_PASSWORD }}
run: |
# Resolve a single admin username/password pair (Secret > Variable > defaults.env) once,
# up front, and reuse it for server bootstrap, token acquisition, and the Playwright run
# below, instead of each resolving (or hardcoding) its own copy.
DEFAULT_ADMIN_USERNAME=$(grep '^ADMIN_USERNAME=' tests/e2e/defaults.env | cut -d= -f2-)
DEFAULT_ADMIN_PASSWORD=$(grep '^ADMIN_PASSWORD=' tests/e2e/defaults.env | cut -d= -f2-)
ADMIN_USERNAME="${SECRET_ADMIN_USERNAME:-${VAR_ADMIN_USERNAME:-$DEFAULT_ADMIN_USERNAME}}"
ADMIN_PASSWORD="${SECRET_ADMIN_PASSWORD:-$DEFAULT_ADMIN_PASSWORD}"
echo "::add-mask::$ADMIN_PASSWORD"
echo "ADMIN_USERNAME=$ADMIN_USERNAME" >> "$GITHUB_ENV"
echo "ADMIN_PASSWORD=$ADMIN_PASSWORD" >> "$GITHUB_ENV"
echo "✅ Resolved admin credentials (Secret > Variable > defaults.env)"
- name: 📋 Load E2E Default Configuration
run: |
# Loads tests/e2e/defaults.env (the canonical fixed dataset, shared with run-e2e.sh
# for local runs) as DEFAULT_* so later steps can fall back to it.
while IFS='=' read -r key value; do
case "$key" in ''|'#'*) continue ;; esac
echo "DEFAULT_${key}=${value}" >> "$GITHUB_ENV"
done < tests/e2e/defaults.env
- name: 🚀 Start the Server
run: |
# Redirect the backend's Google and GitHub endpoints to the mock servers the social login
# specs start, mirroring what run-e2e.sh does locally. Else the specs would use the real
# accounts.google.com/github.com.
cat >> tests/e2e/server/deployment.yaml <<EOF
identity_provider:
google_base_url: "$DEFAULT_MOCK_GOOGLE_BASE_URL"
github_base_url: "$DEFAULT_MOCK_GITHUB_BASE_URL"
EOF
echo "✅ Pointed identity provider endpoints at the mock servers"
cd tests/e2e/server
./setup.sh
# In container jobs, GitHub Actions reaps a step's descendant processes when the step's
# shell exits. `setsid` runs start.sh in a new session detached from the shell so it
# survives the step boundary and remains up when the Playwright test step runs later.
setsid ./start.sh > "$GITHUB_WORKSPACE/server.log" 2>&1 < /dev/null &
SERVER_PID=$!
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
disown $SERVER_PID 2>/dev/null || true
# Wait for server to be ready
echo "Waiting for server to be ready on https://localhost:8090..."
for i in {1..60}; do
if curl -s -k https://localhost:8090/health/liveness > /dev/null; then
echo "Server is UP!"
break
fi
echo "Still waiting ($i/60)..."
sleep 2
done
# Verify server is actually running
if ! curl -s -k https://localhost:8090/health/liveness > /dev/null; then
echo "Server failed to start within 2 minutes"
exit 1
fi
echo "Server started successfully!"
- name: 🔑 Obtain Admin Access Token
id: admin-token
uses: ./.github/actions/obtain-admin-token
with:
admin-username: ${{ env.ADMIN_USERNAME }}
admin-password: ${{ env.ADMIN_PASSWORD }}
- name: 📦 Import Vanilla Sample Resources
uses: ./.github/actions/import-declarative-config
with:
config-path: samples/apps/vanilla-sample/thunderid-config/basic/thunderid-config.yaml
admin-token: ${{ steps.admin-token.outputs.token }}
label: Vanilla Sample resources
variables-json: |
{
"SAMPLE_APP_FLOW_SECRET": "sample-app-flow-secret",
"SAMPLE_APP_GOOGLE_CLIENT_ID": "test-google-client-id",
"SAMPLE_APP_GOOGLE_CLIENT_SECRET": "test-google-client-secret",
"SAMPLE_APP_GOOGLE_REDIRECT_URI": "https://localhost:3000/",
"SAMPLE_APP_GOOGLE_SCOPES": "openid,email,profile",
"SAMPLE_APP_GITHUB_CLIENT_ID": "test-github-client-id",
"SAMPLE_APP_GITHUB_CLIENT_SECRET": "test-github-client-secret",
"SAMPLE_APP_GITHUB_REDIRECT_URI": "https://localhost:3000/",
"SAMPLE_APP_SMS_SENDER_ID": "test-sms-sender-id"
}
- name: 📦 Import React SDK Sample Resources
uses: ./.github/actions/import-declarative-config
with:
config-path: samples/apps/react-sdk-sample/thunderid-config/thunderid-config.yaml
admin-token: ${{ steps.admin-token.outputs.token }}
label: React SDK Sample resources
variables-json: |
{
"REACT_SDK_SAMPLE_CLIENT_ID": "REACT_SDK_SAMPLE",
"REACT_SDK_SAMPLE_REDIRECT_URIS": ["http://localhost:3000", "https://localhost:3000"],
"REACT_SDK_SAMPLE_POST_LOGOUT_REDIRECT_URIS": ["http://localhost:3000", "https://localhost:3000"]
}
- name: 📦 Import E2E Admin App
uses: ./.github/actions/import-declarative-config
with:
config-path: tests/e2e/thunderid-config.yaml
admin-token: ${{ steps.admin-token.outputs.token }}
label: E2E admin app
- name: 📦 Import E2E Sample-App Infrastructure
uses: ./.github/actions/import-declarative-config
with:
config-path: tests/e2e/thunderid-config-sample-apps.yaml
admin-token: ${{ steps.admin-token.outputs.token }}
label: E2E sample-app infrastructure
- name: 🔄 Restart the Server with Security Enabled
run: |
cd tests/e2e/server
echo "Stopping the server..."
# Kill the start.sh wrapper process
kill $SERVER_PID 2>/dev/null || true
# Also kill any process still listening on port 8090
# (start.sh backgrounds the server binary, so killing start.sh
# may leave the server process running as an orphan)
sleep 2
if lsof -ti tcp:8090 >/dev/null 2>&1; then
echo "Port 8090 still in use, killing remaining processes..."
kill -9 $(lsof -ti tcp:8090) 2>/dev/null || true
fi
# Wait for port to be fully released
sleep 3
echo "Starting the server with security enabled..."
# See "Start the Server" step for why setsid is needed inside container jobs.
setsid ./start.sh > "$GITHUB_WORKSPACE/server.log" 2>&1 < /dev/null &
SERVER_PID=$!
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
disown $SERVER_PID 2>/dev/null || true
# Wait for server to be ready
echo "Waiting for server to be ready on https://localhost:8090..."
for i in {1..60}; do
if curl -s -k https://localhost:8090/health/liveness > /dev/null; then
echo "Server is UP with security enabled!"
break
fi
echo "Still waiting ($i/60)..."
sleep 2
done
# Verify server is actually running
if ! curl -s -k https://localhost:8090/health/liveness > /dev/null; then
echo "Server failed to restart within 2 minutes"
exit 1
fi
echo "Server restarted successfully with security enabled!"
- name: 📱 Extract and Start Sample App
run: |
echo "Extracting Sample App..."
mkdir -p tests/e2e/sample-app
# Find the sample app zip file (platform-specific)
SAMPLE_APP_ZIP=$(find target/dist -name "sample-app-react-sdk-*.zip" | head -n 1)
if [ -z "$SAMPLE_APP_ZIP" ]; then
echo "⚠️ Warning: Sample app zip not found in target/dist"
echo "Available files:"
ls -la target/dist/
exit 1
fi
echo "Found sample app: $SAMPLE_APP_ZIP"
unzip -q "$SAMPLE_APP_ZIP" -d tests/e2e/sample-app
cd tests/e2e/sample-app
# Find the extracted folder and move its contents
EXTRACTED_DIR=$(find . -maxdepth 1 -type d -name "sample-app-*" | head -n 1)
if [ -n "$EXTRACTED_DIR" ]; then
mv "$EXTRACTED_DIR"/* .
rmdir "$EXTRACTED_DIR"
fi
echo "Installing dependencies..."
npm install
echo "Generating SSL certificates..."
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout server.key \
-out server.cert \
-subj "/O=ThunderID/OU=${{ env.PRODUCT_NAME }}/CN=localhost" \
> /dev/null 2>&1
echo "Starting Sample App..."
# See "Start the Server" step for why setsid is needed inside container jobs.
SAMPLE_PID=$(setsid bash -c 'npm run dev > "$GITHUB_WORKSPACE/sample-app.log" 2>&1 < /dev/null & echo $!' | tail -1)
echo "SAMPLE_APP_PID=$SAMPLE_PID" >> $GITHUB_ENV
disown $SAMPLE_PID 2>/dev/null || true
# Wait for sample app to be ready
echo "Waiting for sample app to be ready on https://localhost:3000..."
for i in {1..60}; do
if curl -s -k https://localhost:3000 > /dev/null 2>&1; then
echo "Sample App is UP!"
break
fi
echo "Still waiting ($i/60)..."
sleep 2
done
# Verify sample app is running
if ! curl -s -k https://localhost:3000 > /dev/null 2>&1; then
echo "Sample app failed to start within 2 minutes"
exit 1
fi
echo "Sample App started successfully!"
# Scoped to the E2E package: it has no workspace dependencies, so installing
# the whole monorepo here only added time and dragged in native builds from
# unrelated sample apps.
- name: 📦 Install E2E Dependencies
run: pnpm install --frozen-lockfile --filter @thunderid/e2e
# Pull requests run the Chromium project only, and the merge queue runs all
# three browsers, so nothing reaches main without the full matrix. On a pull
# request that is 74 of the 187 tests.
# The Wayfinder specs (tagged @wayfinder) import a config bundle that replaces the
# server-wide CORS allowlist and default resource server (full-replace import semantics),
# so they must not share a server with anything that depends on either setting; run them
# separately, below, against their own freshly provisioned server.
- name: 🎭 Run Playwright E2E Tests (core)
run: |
if [ -n "$PLAYWRIGHT_PROJECT_FILTER" ]; then
echo "Running Playwright projects matching '$PLAYWRIGHT_PROJECT_FILTER'"
pnpm test --project="$PLAYWRIGHT_PROJECT_FILTER" --grep-invert @wayfinder --pass-with-no-tests
else
echo "Running every Playwright project"
pnpm test --grep-invert @wayfinder --pass-with-no-tests
fi
working-directory: tests/e2e
env:
# setup comes along as the chromium project's dependency. Empty on
# merge_group and workflow_dispatch, which run every project.
PLAYWRIGHT_PROJECT_FILTER: ${{ github.event_name == 'pull_request' && 'chromium' || '' }}
# Configuration Priority: Secret > Variable > tests/e2e/defaults.env (loaded above as DEFAULT_*)
# ADMIN_USERNAME/ADMIN_PASSWORD were already resolved once by the "Resolve Admin
# Credentials" step above and reused for server bootstrap and token acquisition; reuse
# that same pair here instead of resolving it again.
BASE_URL: ${{ secrets.PLAYWRIGHT_BASE_URL || vars.PLAYWRIGHT_BASE_URL || env.DEFAULT_BASE_URL }}
ADMIN_USERNAME: ${{ env.ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ env.ADMIN_PASSWORD }}
TEST_USER_USERNAME: ${{ secrets.PLAYWRIGHT_TEST_USER_USERNAME || vars.PLAYWRIGHT_TEST_USER_USERNAME || env.DEFAULT_TEST_USER_USERNAME }}
TEST_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_TEST_USER_PASSWORD || env.DEFAULT_TEST_USER_PASSWORD }}
PLAYWRIGHT_WORKERS: ${{ vars.PLAYWRIGHT_WORKERS || env.DEFAULT_PLAYWRIGHT_WORKERS }}
DEBUG_AUTH: ${{ vars.PLAYWRIGHT_DEBUG_AUTH || env.DEFAULT_DEBUG_AUTH }}
# MFA Test Configuration
SAMPLE_APP_URL: ${{ env.DEFAULT_SAMPLE_APP_URL }}
SERVER_URL: ${{ env.DEFAULT_SERVER_URL }}
AUTO_SETUP_MFA: ${{ vars.AUTO_SETUP_MFA || env.DEFAULT_AUTO_SETUP_MFA }}
MOCK_SMS_SERVER_PORT: ${{ vars.MOCK_SMS_SERVER_PORT || env.DEFAULT_MOCK_SMS_SERVER_PORT }}
# Social Login Test Configuration. The specs bind their mock servers to these URLs and
# skip entirely when unset, so they must match what "Start the Server" wrote into
# deployment.yaml as identity_provider.
MOCK_GOOGLE_BASE_URL: ${{ env.DEFAULT_MOCK_GOOGLE_BASE_URL }}
MOCK_GITHUB_BASE_URL: ${{ env.DEFAULT_MOCK_GITHUB_BASE_URL }}
SAMPLE_APP_USERNAME: ${{ secrets.SAMPLE_APP_USERNAME || vars.SAMPLE_APP_USERNAME || env.DEFAULT_SAMPLE_APP_USERNAME }}
SAMPLE_APP_PASSWORD: ${{ secrets.SAMPLE_APP_PASSWORD || env.DEFAULT_SAMPLE_APP_PASSWORD }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: tests/e2e/playwright-report/
retention-days: 30
- name: 🛑 Stop the Server
if: ${{ !cancelled() }}
run: |
kill $SERVER_PID 2>/dev/null || true
sleep 2
if lsof -ti tcp:8090 >/dev/null 2>&1; then
kill -9 $(lsof -ti tcp:8090) 2>/dev/null || true
fi
sleep 3
rm -rf tests/e2e/server
# A fresh server for the Wayfinder specs: same distribution zip, re-extracted so the CORS
# allowlist and default resource server the previous phase set are gone, and importing only
# the E2E admin app so there is no `Customer` user type for the Wayfinder bundle's own
# `Customer` user type to collide with. Runs regardless of the core phase's result - a
# different server, so the core phase's failure tells us nothing about this one (mirrors
# run-e2e.sh's run_phase, which does the same for the same reason).
- name: 📂 Extract Fresh Server for Wayfinder Tests
if: ${{ !cancelled() }}
run: |
mkdir -p tests/e2e/server
unzip target/dist/$PRODUCT_NAME_LOWER-*.zip -d tests/e2e/server
cd tests/e2e/server
EXTRACTED_DIR=$(find . -maxdepth 1 -type d -name "$PRODUCT_NAME_LOWER-*" | head -n 1)
if [ -n "$EXTRACTED_DIR" ]; then
mv "$EXTRACTED_DIR"/* .
rmdir "$EXTRACTED_DIR"
fi
chmod +x setup.sh start.sh
- name: 🚀 Start the Server (Wayfinder)
if: ${{ !cancelled() }}
run: |
cd tests/e2e/server
./setup.sh
setsid ./start.sh > "$GITHUB_WORKSPACE/server-wayfinder.log" 2>&1 < /dev/null &
SERVER_PID=$!
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
disown $SERVER_PID 2>/dev/null || true
echo "Waiting for server to be ready on https://localhost:8090..."
for i in {1..60}; do
if curl -s -k https://localhost:8090/health/liveness > /dev/null; then
echo "Server is UP!"
break
fi
echo "Still waiting ($i/60)..."
sleep 2
done
if ! curl -s -k https://localhost:8090/health/liveness > /dev/null; then
echo "Server failed to start within 2 minutes"
exit 1
fi
echo "Server started successfully!"
- name: 🔑 Obtain Admin Access Token (Wayfinder)
if: ${{ !cancelled() }}
id: admin-token-wayfinder
uses: ./.github/actions/obtain-admin-token
with:
admin-username: ${{ env.ADMIN_USERNAME }}
admin-password: ${{ env.ADMIN_PASSWORD }}
- name: 📦 Import E2E Admin App (Wayfinder)
if: ${{ !cancelled() }}
uses: ./.github/actions/import-declarative-config
with:
config-path: tests/e2e/thunderid-config.yaml
admin-token: ${{ steps.admin-token-wayfinder.outputs.token }}
label: E2E admin app
- name: 🚀 Start Wayfinder Sample App
if: ${{ !cancelled() }}
run: |
cd samples/apps/wayfinder-sample/frontend
cp .env.example .env
npm install
setsid npm run dev > "$GITHUB_WORKSPACE/wayfinder-app.log" 2>&1 < /dev/null &
disown $! 2>/dev/null || true
echo "Waiting for Wayfinder sample app on http://localhost:5173..."
for i in {1..60}; do
if curl -s http://localhost:5173 > /dev/null; then
echo "Wayfinder sample app is up!"
break
fi
sleep 2
done
if ! curl -s http://localhost:5173 > /dev/null; then
echo "❌ Wayfinder sample app failed to start within 2 minutes"
echo "--- wayfinder-app.log ---"
cat "$GITHUB_WORKSPACE/wayfinder-app.log" || true
exit 1
fi
- name: 🗂️ Cache Wayfinder Mock SMTP Server Dependencies
if: ${{ !cancelled() }}
id: cache-wayfinder-smtp
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: samples/apps/wayfinder-sample/smtp-server/node_modules
key: wayfinder-smtp-server-${{ runner.os }}-${{ hashFiles('samples/apps/wayfinder-sample/smtp-server/package.json') }}
- name: 🚀 Start Wayfinder Mock SMTP Server
if: ${{ !cancelled() }}
run: |
cd samples/apps/wayfinder-sample/smtp-server
if [ "${{ steps.cache-wayfinder-smtp.outputs.cache-hit }}" != "true" ]; then
npm install
fi
npm run build
setsid node src/index.js > "$GITHUB_WORKSPACE/wayfinder-smtp-server.log" 2>&1 < /dev/null &
disown $! 2>/dev/null || true
# Defaults (127.0.0.1:2525 SMTP, 127.0.0.1:8788 inbox) match the server distribution's
# deployment.yaml email.smtp settings exactly, so no .env or config changes are needed.
echo "Waiting for Wayfinder mock SMTP inbox on http://localhost:8788..."
for i in {1..60}; do
if curl -s http://localhost:8788/health > /dev/null; then
echo "Wayfinder mock SMTP inbox is up!"
break
fi
sleep 2
done
if ! curl -s http://localhost:8788/health > /dev/null; then
echo "❌ Wayfinder mock SMTP inbox failed to start within 2 minutes"
echo "--- wayfinder-smtp-server.log ---"
cat "$GITHUB_WORKSPACE/wayfinder-smtp-server.log" || true
exit 1
fi
- name: 🎭 Run Playwright E2E Tests (Wayfinder)
if: ${{ !cancelled() }}
run: |
if [ -n "$PLAYWRIGHT_PROJECT_FILTER" ]; then
echo "Running Playwright projects matching '$PLAYWRIGHT_PROJECT_FILTER'"
pnpm test --project $PLAYWRIGHT_PROJECT_FILTER --output=test-results-wayfinder --grep @wayfinder --pass-with-no-tests
else
echo "Running every Playwright project"
pnpm test --output=test-results-wayfinder --grep @wayfinder --pass-with-no-tests
fi
working-directory: tests/e2e
env:
# wayfinder-sample-setup.spec.ts and tests/wayfinder/** run through their own dedicated
# projects (see playwright.config.ts), not the plain chromium/firefox/webkit ones, so PRs
# name the Wayfinder projects explicitly (chromium's tryout project plus the two
# browser-agnostic ones) instead of a 'chromium' or '*wayfinder-*' filter - the former
# would match none of them, the latter would also pull in firefox/webkit tryout runs.
PLAYWRIGHT_PROJECT_FILTER: ${{ github.event_name == 'pull_request' && 'wayfinder-setup chromium-wayfinder-tryout wayfinder-mock-email' || '' }}
PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-wayfinder
PLAYWRIGHT_JSON_OUTPUT_FILE: test-results-wayfinder/test-results.json
PLAYWRIGHT_JUNIT_OUTPUT_FILE: test-results-wayfinder/junit.xml
BASE_URL: ${{ secrets.PLAYWRIGHT_BASE_URL || vars.PLAYWRIGHT_BASE_URL || env.DEFAULT_BASE_URL }}
ADMIN_USERNAME: ${{ env.ADMIN_USERNAME }}
ADMIN_PASSWORD: ${{ env.ADMIN_PASSWORD }}
WAYFINDER_APP_URL: http://localhost:5173
PLAYWRIGHT_WORKERS: ${{ vars.PLAYWRIGHT_WORKERS || env.DEFAULT_PLAYWRIGHT_WORKERS }}
DEBUG_AUTH: ${{ vars.PLAYWRIGHT_DEBUG_AUTH || env.DEFAULT_DEBUG_AUTH }}
SERVER_URL: ${{ env.DEFAULT_SERVER_URL }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: ${{ !cancelled() }}
with:
name: playwright-report-wayfinder
path: tests/e2e/playwright-report-wayfinder/
retention-days: 30
build-docs:
name: 📚 Build Documentation
needs: [preflight]
if: ${{ needs.preflight.outputs.docs-changed == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: 📥 Checkout Code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: ⚙️ Set up Node.js and pnpm
uses: ./.github/actions/setup-pnpm
with:
node-version: ${{ env.NODE_VERSION }}
- name: 📚 Build docs
run: make build_docs
validate-windows:
name: 🪟 Validate Windows PowerShell
needs: preflight
if: needs.preflight.outputs.powershell-changed == 'true'
uses: ./.github/workflows/windows-powershell-validation.yml
cleanup-turbo-cache:
name: 🗑️ Cleanup Stale Turbo Cache
runs-on: ubuntu-latest
# Skip on fork PRs — GITHUB_TOKEN is always read-only there regardless of permissions declared here.
if: always() && github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, 'trigger-pr-builder') && (github.event.action != 'labeled' || github.event.label.name == 'trigger-pr-builder')
needs:
- build
- test-frontend-packages
- test-frontend-gate-app
- test-frontend-console-app
- test-integration
timeout-minutes: 5
permissions:
actions: write
steps:
- name: 🗑️ Delete superseded Turbo and pnpm store caches
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
CACHE_REF: ${{ github.ref }}
run: |
# Both cache families end in "--<run id>-<attempt>", so each run and each
# re-run publishes a fresh entry, and the older ones for the same lineage
# (the key without that suffix) can never be reached by a restore-key
# again. The separator is a
# double dash because a hex lockfile hash cannot contain one, so trimming
# it can never mangle a key that predates this scheme; those are left
# alone and expire on their own.
#
# Scoped to this run's ref on purpose: GitHub stores caches per branch,
# and the previous version of this step matched every ref, so it deleted
# other pull requests' warm caches along with this one's stale copies.
gh api --paginate \
"repos/${{ github.repository }}/actions/caches?per_page=100&ref=${CACHE_REF}" \
--jq '.actions_caches[]
| select(.key | test("^(turbo|pnpm-store)-"))
| select(.key | test("--[0-9]+-[0-9]+$"))
| {id, created_at, lineage: (.key | sub("--[0-9]+-[0-9]+$"; ""))}' \
| jq -s 'group_by(.lineage)[]
| sort_by(.created_at) | reverse | .[1:] | .[].id' \
| xargs -r -I{} gh api --method DELETE "repos/${{ github.repository }}/actions/caches/{}"