diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bd84b6e81..f978b7e9fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,7 +153,11 @@ jobs: - 'test/unit/miner-calibration-types.test.ts' # Path-aware validation. Keep this as one job so a PR uses one dependency - # install and one coverage upload. Run on GitHub-hosted runners while the + # install for the fast checks (drift/typecheck/build/UI toolchain). The + # expensive full-suite coverage run is sharded out to validate-tests below + # (#ci-shard-coverage) -- it dominated wall-clock time (~9-10 of ~11 minutes + # on a typical backend PR) and doesn't benefit from sharing an install with + # these much-faster steps. Run on GitHub-hosted runners while the # self-hosted review stack is CPU constrained. validate-code: name: validate-code @@ -280,28 +284,170 @@ jobs: - name: Build engine package if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' }} run: npm run build --workspace @jsonbored/gittensory-engine - - name: Prepare test reports dir + - name: Worker runtime tests if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} + run: npm run test:workers + # gittensory-mcp now depends on @jsonbored/gittensory-engine for real (isTestFile/isCodeFile), so a + # PR that only touches packages/gittensory-engine/** must also rebuild + pack-check the mcp package, + # not just the mcp filter's own (deliberately narrower) path list. + - name: Build MCP + if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }} + run: npm run build:mcp + - name: MCP package check + if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }} + run: npm run test:mcp-pack + - name: Build miner CLI + if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }} + run: npm run build:miner + - name: Miner package check + if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }} + run: npm run test:miner-pack + # review-enrichment is not an npm workspace member (its own package-lock.json), so it needs its own + # cache entry -- same restore/save-after-success pattern and fork/trusted key split as the root + # install above, for the same reasons. + - name: Restore review-enrichment node_modules cache + id: rees-node-modules-cache + if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: review-enrichment/node_modules + # Same Node-version and manifest guards as the root cache key above -- REES runs on the same + # pinned .nvmrc and has its own package.json lifecycle/install validation. + key: npm-rees-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('review-enrichment/package.json', 'review-enrichment/package-lock.json') }} + - name: REES install + if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && steps.rees-node-modules-cache.outputs.cache-hit != 'true' }} + run: npm run rees:install + - name: Save review-enrichment node_modules cache + if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && steps.rees-node-modules-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: review-enrichment/node_modules + key: ${{ steps.rees-node-modules-cache.outputs.cache-primary-key }} + - name: REES build, source-map validation, and tests + if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }} + run: npm --prefix review-enrichment test + - name: OpenAPI drift check + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }} + run: npm run ui:openapi:check + # #2556: RepositorySettingsSchema (hand-authored Zod) can silently drift from the actual + # RepositorySettings TS type -- the OpenAPI drift check above only verifies the generated spec + # matches the Zod schema, never that the schema matches the type the API actually serializes. + - name: OpenAPI settings-parity check + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }} + run: npm run ui:openapi:settings-parity + # Checks apps/gittensory-ui/src' known-latest MCP version string against the published package, so + # its dependency is `ui` (the file it scans) + `mcp` (the package it checks against) -- NOT the + # OpenAPI contract, which this script never reads. + - name: UI/MCP version audit + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' }} + run: npm run ui:version-audit + - name: UI lint + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} + run: npm run ui:lint + - name: UI typecheck + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} + run: npm run ui:typecheck + - name: UI tests + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} + run: npm run ui:test + # `npm run ui:build` also regenerates apps/gittensory-ui/public/openapi.json (needed for a + # standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift + # check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this + # step runs, that check already ran and passed in this same job -- the committed spec is + # already byte-identical to what regenerating it here would produce. Run ui:build's other two + # steps directly instead of the aggregate script, skipping that redundant regen. + - name: UI build + if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} + run: npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build + + # The full-suite coverage run, sharded (#ci-shard-coverage). This alone was ~9-10 of the ~11 minutes a + # typical backend PR spent in validate-code, because vitest schedules whole test FILES atomically to + # workers -- one unsharded job with --maxWorkers=4 (a standard runner's real CPU budget) has no way to + # use more than 4-way parallelism no matter how many files exist. Splitting the vitest invocation itself + # across N parallel GitHub-hosted jobs (vitest's own --shard=/, not a hand-rolled file list) gives + # N*4-way parallelism instead, at zero extra $ on this public repo (standard runners are free regardless + # of job count -- more jobs consumes more of the shared free pool, not more billable minutes). + # Each shard uploads its own lcov.info + junit report to Codecov; multiple uploads for the same + # commit/PR are additive (Codecov merges them into one patch-coverage view), which is the standard + # pattern for matrix-split test suites. vitest.config.ts already had COVERAGE_NO_THRESHOLDS support + # wired in (a shard only exercises part of the tree, so the global 90% backstop threshold would always + # false-fail per-shard) -- this job is what finally turns it on; previously nothing ever set it. + validate-tests: + name: validate-tests + needs: changes + if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + env: + VITE_GITTENSORY_API_ORIGIN: https://gittensory-api.aethereal.dev + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + # Full history so Codecov can resolve the merge base for patch coverage. + fetch-depth: 0 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Neutralize untrusted npm config + run: rm -f .npmrc + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version-file: .nvmrc + cache: npm + # Same cache key formula as validate-code's own restore/save pair, so a lockfile-unchanged PR gets a + # cache hit here too and skips npm ci entirely. Concurrent jobs racing to save the same key is safe: + # actions/cache/save no-ops (warns, doesn't fail) if another job already wrote that exact key. + - name: Restore node_modules cache + id: node-modules-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + apps/gittensory-ui/node_modules + key: npm-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('package.json', 'apps/*/package.json', 'packages/*/package.json', 'package-lock.json') }} + - name: Install dependencies (retry on transient failures) + if: ${{ steps.node-modules-cache.outputs.cache-hit != 'true' }} + run: | + for attempt in 1 2 3; do + if npm ci --prefer-offline --no-audit --no-fund; then + exit 0 + fi + echo "::warning::npm ci failed (attempt ${attempt}/3); retrying in 10s" + sleep 10 + done + echo "::error::npm ci failed after 3 attempts" + exit 1 + - name: Save node_modules cache + if: ${{ steps.node-modules-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + node_modules + apps/gittensory-ui/node_modules + key: ${{ steps.node-modules-cache.outputs.cache-primary-key }} + # Any backend test run needs the engine package's dist/ built first -- see the identical step's + # comment in validate-code (#ci-engine-build-order) for why. + - name: Build engine package + run: npm run build --workspace @jsonbored/gittensory-engine + - name: Prepare test reports dir run: mkdir -p reports/junit - - name: Test with coverage + - name: Test with coverage (shard ${{ matrix.shard }}/4) id: coverage - if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} env: - VITEST_JUNIT_PATH: reports/junit/vitest.xml - # Push always runs the full suite (keeps the coverage baseline solid); a PR skips only the 6 - # self-contained MCP CLI-harness files when their real dependency surface (the mcpCliHarness filter - # above) didn't change. mcp-output-schemas.test.ts is never in the exclude list -- see that filter's - # comment for why it can't be safely narrowed. + # Disables vitest.config.ts's own global 90% threshold check for THIS per-shard invocation -- a + # single shard only exercises part of the tree, so it would always false-fail. The global-threshold + # "catastrophe net" this disables is NOT dropped from CI, though: validate-tests-merge (below) merges + # all 4 shards' coverage via vitest's own --mergeReports and re-checks the threshold against that + # merged (whole-suite) total, without COVERAGE_NO_THRESHOLDS set. + COVERAGE_NO_THRESHOLDS: "true" + # Same self-contained-test-file narrowing as the pre-sharding job had -- see the `mcpCliHarness`/ + # `minerTestHarness` filter comments in the `changes` job above for the full rationale. SKIP_MCP_CLI_HARNESS: ${{ github.event_name == 'pull_request' && needs.changes.outputs.mcpCliHarness != 'true' }} - # Same self-contained-test-file narrowing as SKIP_MCP_CLI_HARNESS above, for the miner-package tests - # (minerTestHarness filter). Before this, both miner test files ran on every PR that touched ANY - # backend path (virtually all of them, since test/** alone qualifies) even though neither depends on - # root src/** -- so an unrelated PR paid for a real `npm pack --dry-run` of packages/gittensory-miner - # every time, and a miner-only breakage (e.g. #3704's nested lib/calibration/ directory) blocked every - # subsequent unrelated PR's CI instead of staying contained to PRs that actually touch the package. SKIP_MINER_TEST_HARNESS: ${{ github.event_name == 'pull_request' && needs.changes.outputs.minerTestHarness != 'true' }} - # Pinned to the standard GitHub-hosted runner's effective CPU budget; keeping this explicit avoids - # accidental worker-pool thrash if os.cpus() reports a larger host in a future runner image. run: | EXCLUDE_ARGS=() if [ "$SKIP_MCP_CLI_HARNESS" = "true" ]; then @@ -318,28 +464,38 @@ jobs: --exclude "test/unit/miner-calibration-types.test.ts" ) fi - npm run test:coverage -- --maxWorkers=4 "${EXCLUDE_ARGS[@]}" + npm run test:coverage -- --maxWorkers=4 --shard=${{ matrix.shard }}/4 --reporter=default --reporter=blob --reporter=junit --outputFile.blob=blob-report/report-${{ matrix.shard }}.blob --outputFile.junit=reports/junit/vitest.xml "${EXCLUDE_ARGS[@]}" - name: Test failure guidance if: ${{ failure() && steps.coverage.conclusion == 'failure' }} run: | - echo "::error title=Tests::The backend test coverage suite failed." - echo "Coverage itself is gated by Codecov on changed lines (codecov/patch), computed from the complete lcov generated by this job." - echo "Reproduce locally with: 'npm run test:coverage'." + echo "::error title=Tests::The backend test coverage suite failed (shard ${{ matrix.shard }}/4)." + echo "Coverage itself is gated by Codecov on changed lines (codecov/patch), computed from all shards' merged lcov." + echo "Reproduce locally with: 'npm run test:coverage' (unsharded, runs the whole suite)." - name: Verify coverage report exists - if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }} + if: ${{ success() }} run: | if [ ! -s coverage/lcov.info ]; then echo "::error title=Coverage::coverage/lcov.info is missing or empty" exit 1 fi - # Direct upload for trusted contexts (push + same-repo PRs), where secrets.CODECOV_TOKEN is - # available. + # Consumed by validate-tests-merge to re-check the global coverage threshold against all 4 shards + # combined -- see this job's own header comment. + - name: Upload coverage blob report + if: ${{ success() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-blob-shard-${{ matrix.shard }} + path: blob-report/report-${{ matrix.shard }}.blob + retention-days: 1 + # Direct upload for trusted contexts (push + same-repo PRs). Multiple shards' uploads for the same + # commit/PR are additive in Codecov -- see this job's own header comment. - name: Upload coverage to Codecov - if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') && github.event.pull_request.head.repo.fork != true }} + if: ${{ success() && github.event.pull_request.head.repo.fork != true }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info + flags: shard-${{ matrix.shard }} disable_search: true override_branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }} override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} @@ -347,26 +503,16 @@ jobs: # Coverage upload is part of the hard gate: upload or service errors # should fail CI instead of allowing a PR to merge without patch data. fail_ci_if_error: true - # Fork PRs run without secrets, so they cannot use the token path above. codecov-action v4+ supports - # tokenless upload for public repos, but its commit auto-detection cannot be trusted here: for - # pull_request events GITHUB_SHA is the ephemeral auto-merge commit, and codecov-cli's fallback to - # recover the real head sha assumes HEAD is that 2-parent merge commit -- our checkout step above - # deliberately checks out github.event.pull_request.head.sha directly (so tests run the contributor's - # actual commit, not a synthetic merge), so HEAD has one parent and that recovery can't fire. Pass - # the same explicit overrides as the trusted upload above so the report attaches to the real PR head, - # not a merge sha GitHub's PR checks list has no reason to display. - # - # override_branch is prefixed with the fork owner (owner:branch) -- per Codecov's own docs, only a - # branch string containing a colon is recognized as "unprotected" and eligible for a tokenless - # upload; a bare branch name looks like it could be a real (possibly protected) branch on the base - # repo and gets rejected with "Token required because branch is protected" even with no token - # configured at all. codecov-cli's own auto-detection never adds this prefix either (verified in its - # source), so this must be supplied explicitly. + # Fork PRs run without secrets -- see the identical step's comment in the old single-job validate-code + # (preserved in git history) for the full tokenless-upload rationale (branch-prefix requirement, sha + # override, etc). Condition dropped the push-or-backend check since this job's own `if:` already + # covers it. - name: Upload coverage to Codecov (fork PR tokenless) - if: ${{ success() && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.fork == true }} + if: ${{ success() && github.event.pull_request.head.repo.fork == true }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: ./coverage/lcov.info + flags: shard-${{ matrix.shard }} disable_search: true override_branch: ${{ github.event.pull_request.head.repo.owner.login }}:${{ github.event.pull_request.head.ref }} override_commit: ${{ github.event.pull_request.head.sha }} @@ -376,103 +522,85 @@ jobs: # their upload non-blocking so a JUnit ingestion hiccup does not fail CI # after the tests and hard coverage upload have already passed. - name: Upload Vitest results to Codecov - if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') && github.event.pull_request.head.repo.fork != true }} + if: ${{ !cancelled() && github.event.pull_request.head.repo.fork != true }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./reports/junit/vitest.xml report_type: test_results + flags: shard-${{ matrix.shard }} disable_search: true override_branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }} override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }} fail_ci_if_error: false - name: Upload Vitest results to Codecov (fork PR tokenless) - if: ${{ !cancelled() && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.fork == true }} + if: ${{ !cancelled() && github.event.pull_request.head.repo.fork == true }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: ./reports/junit/vitest.xml report_type: test_results + flags: shard-${{ matrix.shard }} disable_search: true override_branch: ${{ github.event.pull_request.head.repo.owner.login }}:${{ github.event.pull_request.head.ref }} override_commit: ${{ github.event.pull_request.head.sha }} override_pr: ${{ github.event.pull_request.number }} fail_ci_if_error: false - - name: Worker runtime tests - if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} - run: npm run test:workers - # gittensory-mcp now depends on @jsonbored/gittensory-engine for real (isTestFile/isCodeFile), so a - # PR that only touches packages/gittensory-engine/** must also rebuild + pack-check the mcp package, - # not just the mcp filter's own (deliberately narrower) path list. - - name: Build MCP - if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }} - run: npm run build:mcp - - name: MCP package check - if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }} - run: npm run test:mcp-pack - - name: Build miner CLI - if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }} - run: npm run build:miner - - name: Miner package check - if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }} - run: npm run test:miner-pack - # review-enrichment is not an npm workspace member (its own package-lock.json), so it needs its own - # cache entry -- same restore/save-after-success pattern and fork/trusted key split as the root - # install above, for the same reasons. - - name: Restore review-enrichment node_modules cache - id: rees-node-modules-cache - if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }} + + # Re-checks vitest.config.ts's global 90% coverage threshold against all 4 shards MERGED -- each shard + # above deliberately disables that check for itself (COVERAGE_NO_THRESHOLDS=true), since a single shard's + # partial view would always false-fail it. This is the "loose catastrophe net" (e.g. a deleted test file) + # vitest.config.ts's own comment describes; Codecov's patch gate (changed-lines only) doesn't cover a + # whole-repo regression outside the diff, so this is what actually restores that backstop for CI, using + # vitest's own --mergeReports against each shard's uploaded blob report. + validate-tests-merge: + name: validate-tests-merge + needs: [changes, validate-tests] + if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Neutralize untrusted npm config + run: rm -f .npmrc + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version-file: .nvmrc + cache: npm + # Same cache key as validate-code/validate-tests -- a cache hit here is the common case since those + # jobs run concurrently and one of them usually wins the race to populate it first. + - name: Restore node_modules cache + id: node-modules-cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: review-enrichment/node_modules - # Same Node-version and manifest guards as the root cache key above -- REES runs on the same - # pinned .nvmrc and has its own package.json lifecycle/install validation. - key: npm-rees-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('review-enrichment/package.json', 'review-enrichment/package-lock.json') }} - - name: REES install - if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && steps.rees-node-modules-cache.outputs.cache-hit != 'true' }} - run: npm run rees:install - - name: Save review-enrichment node_modules cache - if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && steps.rees-node-modules-cache.outputs.cache-hit != 'true' }} - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + path: | + node_modules + apps/gittensory-ui/node_modules + key: npm-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('package.json', 'apps/*/package.json', 'packages/*/package.json', 'package-lock.json') }} + - name: Install dependencies (retry on transient failures) + if: ${{ steps.node-modules-cache.outputs.cache-hit != 'true' }} + run: | + for attempt in 1 2 3; do + if npm ci --prefer-offline --no-audit --no-fund; then + exit 0 + fi + echo "::warning::npm ci failed (attempt ${attempt}/3); retrying in 10s" + sleep 10 + done + echo "::error::npm ci failed after 3 attempts" + exit 1 + - name: Download all shards' blob reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - path: review-enrichment/node_modules - key: ${{ steps.rees-node-modules-cache.outputs.cache-primary-key }} - - name: REES build, source-map validation, and tests - if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }} - run: npm --prefix review-enrichment test - - name: OpenAPI drift check - if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }} - run: npm run ui:openapi:check - # #2556: RepositorySettingsSchema (hand-authored Zod) can silently drift from the actual - # RepositorySettings TS type -- the OpenAPI drift check above only verifies the generated spec - # matches the Zod schema, never that the schema matches the type the API actually serializes. - - name: OpenAPI settings-parity check - if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }} - run: npm run ui:openapi:settings-parity - # Checks apps/gittensory-ui/src' known-latest MCP version string against the published package, so - # its dependency is `ui` (the file it scans) + `mcp` (the package it checks against) -- NOT the - # OpenAPI contract, which this script never reads. - - name: UI/MCP version audit - if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' }} - run: npm run ui:version-audit - - name: UI lint - if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} - run: npm run ui:lint - - name: UI typecheck - if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} - run: npm run ui:typecheck - - name: UI tests - if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} - run: npm run ui:test - # `npm run ui:build` also regenerates apps/gittensory-ui/public/openapi.json (needed for a - # standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift - # check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this - # step runs, that check already ran and passed in this same job -- the committed spec is - # already byte-identical to what regenerating it here would produce. Run ui:build's other two - # steps directly instead of the aggregate script, skipping that redundant regen. - - name: UI build - if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} - run: npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build + pattern: coverage-blob-shard-* + path: all-blob-reports + merge-multiple: true + - name: Merge shard coverage and check the global threshold + run: npx vitest run --coverage --mergeReports=all-blob-reports # Diff-scoped security gate: fails only on vulnerabilities this PR introduces. # Ambient advisories in untouched deps are handled by Renovate + the scheduled @@ -498,12 +626,12 @@ jobs: comment-summary-in-pr: on-failure # Single required status check. Branch protection points at "validate"; this - # aggregates the path-aware job and the PR-only dependency review gate so that + # aggregates the path-aware jobs and the PR-only dependency review gate so that # requirement keeps working unchanged. # Path-filtered jobs report "skipped", which is treated as success. validate: name: validate - needs: [changes, validate-code, security] + needs: [changes, validate-code, validate-tests, validate-tests-merge, security] if: ${{ always() }} # Pure result-aggregation (reads needs.*.result, echoes pass/fail) -- no build/test work, so it never # needed the self-hosted pool's cached toolchain (#2507). diff --git a/test/unit/codecov-policy.test.ts b/test/unit/codecov-policy.test.ts index ac4423a719..3738179465 100644 --- a/test/unit/codecov-policy.test.ts +++ b/test/unit/codecov-policy.test.ts @@ -36,8 +36,11 @@ describe("Codecov policy", () => { it("fails closed when the backend coverage report is missing or cannot upload", () => { const workflow = readYaml(".github/workflows/ci.yml"); - const validateCode = nestedRecord(workflow, ["jobs", "validate-code"]); - const steps = recordArray(validateCode.steps, "jobs.validate-code.steps"); + // The full-suite coverage run (and its Codecov uploads) lives in validate-tests, sharded out of + // validate-code (#ci-shard-coverage) so the ~9-10min run no longer serializes with the much-faster + // drift/typecheck/build checks that stayed behind in validate-code. + const validateTests = nestedRecord(workflow, ["jobs", "validate-tests"]); + const steps = recordArray(validateTests.steps, "jobs.validate-tests.steps"); const stepNames = steps.map((step) => step.name); const verifyIndex = stepNames.indexOf("Verify coverage report exists"); @@ -52,10 +55,11 @@ describe("Codecov policy", () => { const coverageUpload = steps[coverageUploadIndex]!; const testResultsUpload = steps[testResultsUploadIndex]!; - // Verify must run whenever coverage was generated at all (push or backend==true) -- it deliberately - // does NOT exclude forks, since both the trusted and the tokenless fork upload path below it need - // the report to exist first. - expect(String(verifyStep.if)).toBe("${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }}"); + // Verify must run whenever coverage was generated at all -- the job's own top-level `if:` (push or + // backend==true) already gates the whole matrix, so the step itself only needs `success()`. It + // deliberately does NOT exclude forks, since both the trusted and the tokenless fork upload path + // below it need the report to exist first. + expect(String(verifyStep.if)).toBe("${{ success() }}"); expect(String(coverageUpload.if)).toContain(String(verifyStep.if).replace(/^\$\{\{\s*|\s*\}\}$/g, "")); expect(String(verifyStep.run)).toContain("coverage/lcov.info is missing or empty"); expect(String(verifyStep.run)).toContain("exit 1"); @@ -79,8 +83,8 @@ describe("Codecov policy", () => { // (public repos only) closes that gap with a single, synchronous, same-job upload: no separate // workflow, no artifact staging, no fork-authored attribution data to trust or validate. const workflow = readYaml(".github/workflows/ci.yml"); - const validateCode = nestedRecord(workflow, ["jobs", "validate-code"]); - const steps = recordArray(validateCode.steps, "jobs.validate-code.steps"); + const validateTests = nestedRecord(workflow, ["jobs", "validate-tests"]); + const steps = recordArray(validateTests.steps, "jobs.validate-tests.steps"); const verifyStep = steps.find((step) => step.name === "Verify coverage report exists"); expect(verifyStep).toBeDefined(); diff --git a/test/unit/workflow-runner-labels.test.ts b/test/unit/workflow-runner-labels.test.ts index 6bddec57f9..72afe6fa31 100644 --- a/test/unit/workflow-runner-labels.test.ts +++ b/test/unit/workflow-runner-labels.test.ts @@ -17,7 +17,7 @@ describe("workflow runner labels", () => { expect(workflow).not.toContain("|| 'self-hosted'"); expect(workflow).not.toContain('"fork-ci"'); expect(workflow).toContain("validate-code:"); - expect(workflow).toContain("needs: [changes, validate-code, security]"); + expect(workflow).toContain("needs: [changes, validate-code, validate-tests, validate-tests-merge, security]"); expect(workflow).not.toContain("\n lint:\n"); expect(workflow).not.toContain("\n test:\n"); expect(workflow).not.toContain("\n workers:\n"); @@ -27,8 +27,16 @@ describe("workflow runner labels", () => { const changesJob = workflow.slice(workflow.indexOf("\n changes:\n"), workflow.indexOf("\n validate-code:\n")); expect(changesJob).toContain("runs-on: ubuntu-latest"); - const validateCodeJob = workflow.slice(workflow.indexOf("\n validate-code:\n"), workflow.indexOf("\n security:\n")); + const validateCodeJob = workflow.slice(workflow.indexOf("\n validate-code:\n"), workflow.indexOf("\n validate-tests:\n")); expect(validateCodeJob).toContain("runs-on: ubuntu-latest"); + // validate-tests (#ci-shard-coverage) is the matrix-sharded full-suite coverage run, split out of + // validate-code so the dominant ~9-10min step no longer serializes with the much-faster checks. + const validateTestsJob = workflow.slice(workflow.indexOf("\n validate-tests:\n"), workflow.indexOf("\n validate-tests-merge:\n")); + expect(validateTestsJob).toContain("runs-on: ubuntu-latest"); + // validate-tests-merge re-checks the global coverage threshold against all 4 shards merged -- see its + // own header comment in ci.yml. + const validateTestsMergeJob = workflow.slice(workflow.indexOf("\n validate-tests-merge:\n"), workflow.indexOf("\n security:\n")); + expect(validateTestsMergeJob).toContain("runs-on: ubuntu-latest"); const securityJob = workflow.slice(workflow.indexOf("\n security:\n"), workflow.indexOf("\n validate:\n")); expect(securityJob).toContain("runs-on: ubuntu-latest"); const validateJob = workflow.slice(workflow.indexOf("\n validate:\n"));