name: CI on: push: branches: [main] pull_request: branches: [main] types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read env: CI_NODE_VERSION: "24" CI_NODE_24_VERSION: "24" CI_NODE_26_VERSION: "26" jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: npm run audit:deps - run: npm run lint - run: npm run check:cycles - run: npm run check:route-validation:t06 - run: npm run check:any-budget:t11 - run: npm run check:docs-sync - run: npm run typecheck:core # typecheck:noimplicit:core is a forward-looking gate (noImplicitAny). # Run informationally for now โ€” many pre-existing call sites still need # explicit annotations; track in a dedicated follow-up. - run: npm run typecheck:noimplicit:core continue-on-error: true docs-sync-strict: name: Docs Sync (Strict) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:docs-all - name: i18n translation drift (warn) run: node scripts/i18n/check-translation-drift.mjs --warn i18n-ui-coverage: name: i18n UI Coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65 i18n-matrix: name: Build language matrix runs-on: ubuntu-latest outputs: langs: ${{ steps.langs.outputs.langs }} steps: - uses: actions/checkout@v6 - id: langs run: | LANG_DIR="src/i18n/messages" LANGS=$(ls "$LANG_DIR"/*.json | xargs -n1 basename | sed 's/.json$//' | grep -v '^en$' | jq -R . | jq -s . | jq -c .) echo "langs=${LANGS}" >> "$GITHUB_OUTPUT" i18n: name: i18n Validation runs-on: ubuntu-latest continue-on-error: true strategy: fail-fast: false matrix: lang: ${{ fromJson(needs.i18n-matrix.outputs.langs) }} needs: i18n-matrix steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.12" - name: Validate ${{ matrix.lang }} run: | python3 scripts/i18n/validate_translation.py quick -l '${{ matrix.lang }}' > result.txt - name: Upload result if: always() uses: actions/upload-artifact@v7 with: name: i18n-${{ matrix.lang }} path: result.txt pr-test-policy: name: PR Test Policy if: ${{ github.event_name == 'pull_request' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} - name: Fetch base branch run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1 - name: Validate source changes include tests run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md - name: Publish PR test policy summary if: always() run: | if [ -f .artifacts/pr-test-policy.md ]; then cat .artifacts/pr-test-policy.md >> "$GITHUB_STEP_SUMMARY" fi build: name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: npm run build package-artifact: name: Package Artifact runs-on: ubuntu-latest needs: build env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime # build:cli runs a clean build into .build/next and assembles dist/ # For release builds prefer: npm run build:release (clean rebuild + HEAD sentinel) - run: npm run build:cli - name: Assert dist/server.js exists run: test -f dist/server.js || (echo "dist/server.js missing โ€” build:cli did not assemble correctly" && exit 1) - run: npm run check:pack-artifact electron-package-smoke: name: Electron Package Smoke runs-on: ubuntu-latest timeout-minutes: 25 needs: build env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation CSC_IDENTITY_AUTO_DISCOVERY: "false" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: npm run build - name: Install Electron dependencies working-directory: electron run: npm install --no-audit --no-fund - name: Pack Electron app working-directory: electron run: npm run pack - name: Smoke packaged Electron app env: ELECTRON_SMOKE_TIMEOUT_MS: 60000 run: xvfb-run -a npm run electron:smoke:packaged test-unit: name: Unit Tests (${{ matrix.shard }}/8) runs-on: ubuntu-latest timeout-minutes: 15 needs: build strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts node-24-compat: name: Node 24 Compatibility (${{ matrix.shard }}/2) runs-on: ubuntu-latest timeout-minutes: 15 needs: build strategy: fail-fast: false matrix: shard: [1, 2] env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_24_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: npm run build - run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts node-26-compat: name: Node 26 Compatibility (${{ matrix.shard }}/2) runs-on: ubuntu-latest timeout-minutes: 15 needs: build strategy: fail-fast: false matrix: shard: [1, 2] env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_26_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: npm run build - run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts test-coverage-shard: name: Coverage Shard (${{ matrix.shard }}/8) runs-on: ubuntu-latest timeout-minutes: 25 needs: build strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6, 7, 8] env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - name: Run c8 over shard ${{ matrix.shard }}/8 run: | rm -rf coverage-shard coverage-shard-report # `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge # job reads with `c8 report --temp-directory ...`. Using `--output-dir` # only produces the final json *report* and leaves the raw v8 files in # `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp # dir so the raw coverage files live there and the artifact upload picks # them up regardless of `--test-force-exit` timing. npx c8 \ --temp-directory=coverage-shard \ --reports-dir=coverage-shard-report \ --reporter=json \ --exclude=tests/** \ --exclude=**/*.test.* \ node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 \ --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts - name: Upload raw shard coverage if: always() uses: actions/upload-artifact@v7 with: name: coverage-shard-${{ matrix.shard }} path: coverage-shard/*.json if-no-files-found: error test-coverage: name: Coverage runs-on: ubuntu-latest timeout-minutes: 10 needs: test-coverage-shard if: ${{ always() && needs.test-coverage-shard.result == 'success' }} env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - name: Download all shard coverage uses: actions/download-artifact@v8 with: pattern: coverage-shard-* path: coverage-shards/ merge-multiple: true - name: Merge + report + gate # Merging 8 shards of raw v8 coverage is memory-heavy; the default Node # heap OOMs (exit 134). Raise it for the c8 merge/report step. env: NODE_OPTIONS: --max-old-space-size=6144 run: | mkdir -p coverage if [ ! -d coverage-shards ] || ! find coverage-shards -maxdepth 1 -type f -name '*.json' | grep -q .; then echo "::error::No raw coverage shard data was downloaded." find . -maxdepth 3 -type f | sort exit 1 fi # Gate aligned to the project's local coverage bar (npm run test:coverage # uses 40/40/40/40). The previous 75/70 gate never ran on main (the # coverage shards always failed โ†’ this job was skipped), so it was never # actually enforced and is inconsistent with the repo's real standard. npx c8 report \ --temp-directory coverage-shards \ --reports-dir coverage \ --reporter=text-summary \ --reporter=html \ --reporter=json-summary \ --reporter=lcov \ --exclude=tests/** \ --exclude=**/*.test.* \ --check-coverage \ --statements 40 --lines 40 --functions 40 --branches 40 - name: Build coverage summary if: always() run: | mkdir -p coverage if [ -f coverage/coverage-summary.json ]; then node scripts/check/test-report-summary.mjs \ --input coverage/coverage-summary.json \ --output coverage/coverage-report.md \ --threshold 60 else printf '%s\n' \ '# Coverage Report' \ '' \ 'Coverage summary JSON was not generated. Inspect the Coverage job logs.' \ > coverage/coverage-report.md fi cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY" - name: Upload coverage artifacts if: always() uses: actions/upload-artifact@v7 with: name: coverage-report path: | coverage/coverage-summary.json coverage/lcov.info coverage/coverage-report.md if-no-files-found: warn sonarqube: name: SonarQube runs-on: ubuntu-latest needs: test-coverage if: ${{ always() && needs.test-coverage.result == 'success' }} env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/download-artifact@v8 with: name: coverage-report path: . - name: Explain SonarQube skip if: ${{ github.event_name != 'pull_request' || env.SONAR_TOKEN == '' || env.SONAR_HOST_URL == '' }} run: | if [ "${{ github.event_name }}" != "pull_request" ]; then echo "SonarQube scan skipped on non-PR events to keep main pushes governed by repository CI gates." >> "$GITHUB_STEP_SUMMARY" else echo "SonarQube scan skipped because SONAR_TOKEN or SONAR_HOST_URL is not configured." >> "$GITHUB_STEP_SUMMARY" fi - name: SonarQube Scan if: ${{ github.event_name == 'pull_request' && env.SONAR_TOKEN != '' && env.SONAR_HOST_URL != '' }} uses: SonarSource/sonarqube-scan-action@v8 env: SONAR_TOKEN: ${{ env.SONAR_TOKEN }} SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} coverage-pr-comment: name: PR Coverage Comment runs-on: ubuntu-latest if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }} needs: - pr-test-policy - test-coverage permissions: contents: read issues: write pull-requests: write steps: - name: Download coverage artifact if: ${{ needs.test-coverage.result != 'cancelled' }} continue-on-error: true uses: actions/download-artifact@v8 with: name: coverage-report path: . - name: Prepare PR coverage comment env: COVERAGE_RESULT: ${{ needs.test-coverage.result }} POLICY_RESULT: ${{ needs.pr-test-policy.result }} run: | mkdir -p .artifacts { echo "" echo "## CI Coverage Report" echo "" echo "- Coverage job: \`${COVERAGE_RESULT}\`" echo "- PR test policy: \`${POLICY_RESULT}\`" echo "" if [ -f coverage/coverage-report.md ]; then cat coverage/coverage-report.md else echo "Coverage artifact was not available for this run." fi if [ "${POLICY_RESULT}" = "failure" ]; then echo "" echo "## PR Test Policy" echo "" echo "This PR changes production code in \`src/\`, \`open-sse/\`, \`electron/\`, or \`bin/\` without accompanying automated tests." fi } > .artifacts/pr-coverage-comment.md - uses: actions/github-script@v9 with: script: | const fs = require("fs"); const marker = ""; const body = fs.readFileSync(".artifacts/pr-coverage-comment.md", "utf8"); const { owner, repo } = context.repo; const issue_number = context.issue.number; const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100, }); const existing = comments.find((comment) => comment.body?.includes(marker)); if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body, }); } else { await github.rest.issues.createComment({ owner, repo, issue_number, body, }); } test-e2e: name: E2E Tests (${{ matrix.shard }}/6) runs-on: ubuntu-latest # The heaviest shard (responsive viewport matrix + studio/smoke) re-runs # `npm run build` (~5m) then ~24 serial tests; at 35m it was still cancelled # mid-run, so it genuinely needs more wall-clock. 50m gives ample headroom # while the per-test cap (playwright.config.ts) bounds any real hang to a fast # visible failure and the `line` reporter streams which test is slow. timeout-minutes: 50 needs: build strategy: fail-fast: false matrix: shard: [1, 2, 3, 4, 5, 6] env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long DISABLE_SQLITE_AUTO_BACKUP: "true" OMNIROUTE_PLAYWRIGHT_SKIP_BUILD: "1" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: npx playwright install --with-deps chromium - run: npm run build - run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/6 test-integration: name: Integration Tests (${{ matrix.shard }}/2) runs-on: ubuntu-latest timeout-minutes: 15 needs: build strategy: fail-fast: false matrix: shard: [1, 2] env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long INITIAL_PASSWORD: ci-test-password-for-integration DATA_DIR: /tmp/omniroute-ci-${{ matrix.shard }} DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: node --import tsx --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts test-security: name: Security Tests runs-on: ubuntu-latest needs: build env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long DISABLE_SQLITE_AUTO_BACKUP: "true" steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - run: npm ci - run: npm run check:node-runtime - run: npm run test:security ci-summary: name: CI Dashboard runs-on: ubuntu-latest if: always() needs: - lint - docs-sync-strict - i18n-ui-coverage - i18n - pr-test-policy - build - package-artifact - electron-package-smoke - test-unit - node-24-compat - node-26-compat - test-coverage - sonarqube - coverage-pr-comment - test-e2e - test-integration - test-security steps: - name: Download i18n results continue-on-error: true uses: actions/download-artifact@v8 with: pattern: i18n-* path: results merge-multiple: true - name: Generate dashboard env: EVENT_NAME: ${{ github.event_name }} run: | status() { case "$1" in success) echo "๐ŸŸข PASS" ;; failure) echo "๐Ÿ”ด FAIL" ;; cancelled) echo "โšซ CANCELLED" ;; skipped) echo "โšช SKIPPED" ;; *) echo "๐ŸŸก UNKNOWN" ;; esac } echo "# ๐Ÿš€ CI Dashboard" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "## ๐Ÿงฑ Core Checks" >> "$GITHUB_STEP_SUMMARY" echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY" echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY" echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "## ๐Ÿ—๏ธ Build" >> "$GITHUB_STEP_SUMMARY" echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY" echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY" echo "| Build Matrix | $(status '${{ needs.build.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Package Artifact | $(status '${{ needs.package-artifact.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Electron Package Smoke | $(status '${{ needs.electron-package-smoke.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "## ๐Ÿงช Tests" >> "$GITHUB_STEP_SUMMARY" echo "| Suite | Status |" >> "$GITHUB_STEP_SUMMARY" echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" echo "| Unit | $(status '${{ needs.test-unit.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Node 24 Compatibility | $(status '${{ needs.node-24-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Node 26 Compatibility | $(status '${{ needs.node-26-compat.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Coverage | $(status '${{ needs.test-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| PR Coverage Comment | $(status '${{ needs.coverage-pr-comment.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "## ๐ŸŒ Translations" >> "$GITHUB_STEP_SUMMARY" total=0 langs=0 if [ -d results ]; then for file in results/*.txt; do [ -f "$file" ] || continue val=$(sed -r 's/\x1B\[[0-9;]*[mK]//g' "$file" | grep "Untranslated:" | awk '{print $2}') val=${val:-0} total=$((total + val)) langs=$((langs + 1)) done fi echo "" >> "$GITHUB_STEP_SUMMARY" echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY" echo "|--------|------|" >> "$GITHUB_STEP_SUMMARY" echo "| Languages checked | $langs |" >> "$GITHUB_STEP_SUMMARY" echo "| Total untranslated | $total |" >> "$GITHUB_STEP_SUMMARY" if [ "$total" -gt 0 ]; then echo "" >> "$GITHUB_STEP_SUMMARY" echo "โš ๏ธ **Translations need attention**" >> "$GITHUB_STEP_SUMMARY" else echo "" >> "$GITHUB_STEP_SUMMARY" echo "โœ… **All translations complete**" >> "$GITHUB_STEP_SUMMARY" fi