name: CI run-name: >- ${{ (github.event_name == 'push' && format('CI - {0} ({1})', github.event.head_commit.message, github.ref_name)) || (github.event_name == 'pull_request' && format('CI - PR #{0}: {1}', github.event.pull_request.number, github.event.pull_request.title)) || (github.event_name == 'schedule' && 'CI - nightly') || 'CI - manual' }} on: workflow_dispatch: schedule: - cron: "17 4 * * *" pull_request: branches: [main] push: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read pull-requests: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" NODE_VERSION: "24" PNPM_VERSION: "10" jobs: dependency-review: # Enable only when repository security analysis supports dependency review. if: github.event_name == 'pull_request' && vars.ENABLE_DEPENDENCY_REVIEW == 'true' runs-on: ubuntu-latest timeout-minutes: 10 continue-on-error: true steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Dependency review uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 detect-changes: name: Detect Changed Paths runs-on: ubuntu-latest needs: [dependency-review] timeout-minutes: 10 if: always() && (needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') outputs: server: ${{ steps.manual.outputs.server || steps.changes.outputs.server }} client: ${{ steps.manual.outputs.client || steps.changes.outputs.client }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Manual and nightly runs include all scopes id: manual if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' shell: bash run: | echo "server=true" >> "$GITHUB_OUTPUT" echo "client=true" >> "$GITHUB_OUTPUT" # For push events dorny/paths-filter diffs github.event.before..github.sha. # New-branch pushes (before = 0000...) are handled by treating all files as changed. # Force-push edge cases (before SHA no longer reachable) may mark all files changed too. - name: Compute changed scopes id: changes if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 with: filters: | server: - 'server/**' - 'packages/**' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - '.github/actions/setup-node-pnpm/action.yml' - '.github/workflows/ci.yml' client: - 'client/**' - 'packages/**' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - '.github/actions/setup-node-pnpm/action.yml' - '.github/workflows/ci.yml' - name: Print change scope shell: bash run: | echo "server=${{ steps.manual.outputs.server || steps.changes.outputs.server }}" echo "client=${{ steps.manual.outputs.client || steps.changes.outputs.client }}" lint: name: Lint (${{ matrix.package_label }}) runs-on: ubuntu-latest needs: [dependency-review, detect-changes] timeout-minutes: 10 if: > always() && (needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') && needs.detect-changes.result == 'success' strategy: fail-fast: false matrix: package: [server, client] include: - package: server package_label: Server - package: client package_label: Client permissions: contents: read steps: - name: Check for package changes id: has-changes shell: bash env: PACKAGE: ${{ matrix.package }} SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }} CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }} run: | if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \ [[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then echo "run=true" >> "$GITHUB_OUTPUT" else echo "run=false" >> "$GITHUB_OUTPUT" fi - name: Checkout if: steps.has-changes.outputs.run == 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js and pnpm if: steps.has-changes.outputs.run == 'true' uses: ./.github/actions/setup-node-pnpm with: node-version: ${{ env.NODE_VERSION }} pnpm-version: ${{ env.PNPM_VERSION }} prefetch: "true" - name: Run lint checks if: steps.has-changes.outputs.run == 'true' shell: bash env: PACKAGE: ${{ matrix.package }} run: | if [[ "$PACKAGE" == "server" ]]; then pnpm --filter server lint:check else pnpm --filter client lint:check fi - name: Run format checks if: steps.has-changes.outputs.run == 'true' shell: bash env: PACKAGE: ${{ matrix.package }} run: | if [[ "$PACKAGE" == "server" ]]; then pnpm --filter server format:check else pnpm --filter client format:check fi typecheck: name: Type Check (${{ matrix.package_label }}) runs-on: ubuntu-latest needs: [dependency-review, detect-changes] timeout-minutes: 10 if: > always() && (needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') && needs.detect-changes.result == 'success' strategy: fail-fast: false matrix: package: [server, client] include: - package: server package_label: Server - package: client package_label: Client permissions: contents: read steps: - name: Check for package changes id: has-changes shell: bash env: PACKAGE: ${{ matrix.package }} SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }} CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }} run: | if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \ [[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then echo "run=true" >> "$GITHUB_OUTPUT" else echo "run=false" >> "$GITHUB_OUTPUT" fi - name: Checkout if: steps.has-changes.outputs.run == 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js and pnpm if: steps.has-changes.outputs.run == 'true' uses: ./.github/actions/setup-node-pnpm with: node-version: ${{ env.NODE_VERSION }} pnpm-version: ${{ env.PNPM_VERSION }} prefetch: "true" - name: Run typecheck if: steps.has-changes.outputs.run == 'true' shell: bash env: PACKAGE: ${{ matrix.package }} run: | if [[ "$PACKAGE" == "server" ]]; then pnpm --filter server type-check else pnpm --filter @bookorbit/types build pnpm --filter client type-check fi tests: name: Tests (${{ matrix.package_label }}) runs-on: ubuntu-latest needs: [dependency-review, detect-changes] timeout-minutes: 20 if: > always() && (needs.dependency-review.result == 'success' || needs.dependency-review.result == 'skipped') && needs.detect-changes.result == 'success' strategy: fail-fast: false matrix: package: [server, client] include: - package: server package_label: Server - package: client package_label: Client permissions: contents: read checks: write steps: - name: Check for package changes id: has-changes shell: bash env: PACKAGE: ${{ matrix.package }} SERVER_CHANGED: ${{ needs.detect-changes.outputs.server }} CLIENT_CHANGED: ${{ needs.detect-changes.outputs.client }} run: | if [[ "$PACKAGE" == "server" && "$SERVER_CHANGED" == "true" ]] || \ [[ "$PACKAGE" == "client" && "$CLIENT_CHANGED" == "true" ]]; then echo "run=true" >> "$GITHUB_OUTPUT" else echo "run=false" >> "$GITHUB_OUTPUT" fi - name: Checkout if: steps.has-changes.outputs.run == 'true' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Node.js and pnpm if: steps.has-changes.outputs.run == 'true' uses: ./.github/actions/setup-node-pnpm with: node-version: ${{ env.NODE_VERSION }} pnpm-version: ${{ env.PNPM_VERSION }} prefetch: "true" - name: Prepare test report directory if: steps.has-changes.outputs.run == 'true' shell: bash run: mkdir -p "$GITHUB_WORKSPACE/test-results/${{ matrix.package }}" - name: Run tests if: steps.has-changes.outputs.run == 'true' shell: bash env: PACKAGE: ${{ matrix.package }} JUNIT_OUTPUT: ${{ github.workspace }}/test-results/${{ matrix.package }}/junit.xml run: | if [[ "$PACKAGE" == "server" ]]; then pnpm --filter server exec vitest --run --coverage --reporter=default --reporter=github-actions --reporter=junit --outputFile.junit="$JUNIT_OUTPUT" else pnpm --filter client exec vitest --run --reporter=default --reporter=junit --outputFile="$JUNIT_OUTPUT" fi - name: Publish test report annotations if: > always() && steps.has-changes.outputs.run == 'true' && hashFiles(format('test-results/{0}/junit.xml', matrix.package)) != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: EnricoMi/publish-unit-test-result-action@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2 with: check_name: ${{ matrix.package == 'server' && 'Server Unit Tests' || 'Client Unit Tests' }} files: ${{ format('test-results/{0}/junit.xml', matrix.package) }} fail_on: nothing comment_mode: off - name: Upload test report artifact if: > always() && steps.has-changes.outputs.run == 'true' && hashFiles(format('test-results/{0}/junit.xml', matrix.package)) != '' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-report-${{ matrix.package }} path: test-results/${{ matrix.package }} retention-days: 7 quality-gate: name: Quality Gate runs-on: ubuntu-latest needs: [dependency-review, detect-changes, lint, typecheck, tests] timeout-minutes: 5 if: always() steps: - name: Fail if any quality job failed if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: | echo "One or more quality jobs failed." exit 1 - name: Quality checks complete run: | echo "dependency-review=${{ needs.dependency-review.result }}" echo "detect-changes=${{ needs.detect-changes.result }}" echo "lint=${{ needs.lint.result }}" echo "typecheck=${{ needs.typecheck.result }}" echo "tests=${{ needs.tests.result }}" e2e-setup: name: Detect E2E Suites runs-on: ubuntu-latest needs: [quality-gate] if: always() && needs.quality-gate.result == 'success' && github.event_name == 'schedule' timeout-minutes: 10 outputs: matrix: ${{ steps.build-matrix.outputs.matrix }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} - name: Build suite matrix id: build-matrix shell: bash env: GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} GITHUB_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} run: | MATRIX=$(node scripts/e2e/select-matrix.mjs) echo "matrix=$(echo "$MATRIX" | jq -c .)" >> "$GITHUB_OUTPUT" echo "selected_suites=$(echo "$MATRIX" | jq -c .)" e2e: needs: [e2e-setup] if: github.event_name == 'schedule' && needs.e2e-setup.outputs.matrix != '[]' strategy: fail-fast: false matrix: suite: ${{ fromJSON(needs.e2e-setup.outputs.matrix) }} permissions: contents: read checks: write uses: ./.github/workflows/e2e-runner.yml with: suite-id: ${{ matrix.suite.id }} check-name: "E2E - ${{ matrix.suite.name }}" artifact-name: "e2e-${{ matrix.suite.id }}-report" job-timeout-minutes: ${{ matrix.suite.timeout }} ci-gate: name: CI Gate runs-on: ubuntu-latest needs: [quality-gate, e2e-setup, e2e] timeout-minutes: 5 if: always() steps: - name: Fail if any job failed or was cancelled if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') run: | echo "One or more CI jobs failed or were cancelled." exit 1 - name: CI complete run: | echo "quality-gate=${{ needs.quality-gate.result }}" echo "e2e-setup=${{ needs.e2e-setup.result }}" echo "e2e=${{ needs.e2e.result }}" ci-performance: name: CI Performance runs-on: ubuntu-latest needs: [ci-gate] if: always() permissions: actions: read contents: read steps: - name: Compare duration with previous successful run shell: bash env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RUN_ID: ${{ github.run_id }} EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail CURRENT_JSON=$(gh api "repos/${GH_REPO}/actions/runs/${RUN_ID}") CURRENT_STARTED=$(echo "$CURRENT_JSON" | jq -r '.run_started_at // .created_at') CURRENT_UPDATED=$(echo "$CURRENT_JSON" | jq -r '.updated_at') CURRENT_DURATION=$(node -e 'const [start, end] = process.argv.slice(1); console.log(Math.max(0, Math.round((Date.parse(end) - Date.parse(start)) / 1000)));' "$CURRENT_STARTED" "$CURRENT_UPDATED") PREVIOUS_JSON=$(gh api "repos/${GH_REPO}/actions/workflows/ci.yml/runs?per_page=30&event=${EVENT_NAME}&status=completed") PREVIOUS_STARTED=$(echo "$PREVIOUS_JSON" | jq -r --arg run_id "${RUN_ID}" '.workflow_runs[] | select((.id | tostring) != $run_id and .conclusion == "success") | (.run_started_at // .created_at)' | head -n1) PREVIOUS_UPDATED=$(echo "$PREVIOUS_JSON" | jq -r --arg run_id "${RUN_ID}" '.workflow_runs[] | select((.id | tostring) != $run_id and .conclusion == "success") | .updated_at' | head -n1) { echo "## CI Runtime" echo "" echo "- Event: \`${EVENT_NAME}\`" echo "- Current run duration: \`${CURRENT_DURATION}s\`" } >> "$GITHUB_STEP_SUMMARY" if [[ -z "$PREVIOUS_STARTED" || -z "$PREVIOUS_UPDATED" ]]; then echo "- Baseline: unavailable (no previous successful run for this event)" >> "$GITHUB_STEP_SUMMARY" exit 0 fi PREVIOUS_DURATION=$(node -e 'const [start, end] = process.argv.slice(1); console.log(Math.max(0, Math.round((Date.parse(end) - Date.parse(start)) / 1000)));' "$PREVIOUS_STARTED" "$PREVIOUS_UPDATED") RATIO=$(node -e 'const [current, previous] = process.argv.slice(1).map(Number); if (previous === 0) { console.log("N/A"); process.exit(0); } console.log((current / previous).toFixed(2));' "$CURRENT_DURATION" "$PREVIOUS_DURATION") echo "- Previous successful run: \`${PREVIOUS_DURATION}s\`" >> "$GITHUB_STEP_SUMMARY" echo "- Ratio (current/previous): \`${RATIO}\`" >> "$GITHUB_STEP_SUMMARY" if [[ "$RATIO" != "N/A" ]] && node -e 'process.exit(Number(process.argv[1]) > 1.2 ? 0 : 1)' "$RATIO"; then echo "::warning::CI duration regression detected: current=${CURRENT_DURATION}s previous=${PREVIOUS_DURATION}s ratio=${RATIO}" fi