fix(engine): floor percentComplete at 0 so it stays in its documented 0-100 range (#6773) #12762
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| # Explicit list because the default (opened/synchronize/reopened) omits ready_for_review -- once the | |
| # heavy jobs below start skipping draft PRs, marking a PR ready must itself trigger a real CI run, not | |
| # wait for the next push. | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| push: | |
| branches: | |
| - main | |
| permissions: | |
| contents: read | |
| concurrency: | |
| # Keep pull requests in one ref-scoped group so newer commits cancel superseded PR runs. | |
| # Add github.sha for push runs so distinct main commits do not cancel each other's validation. | |
| group: ci-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || 'pr' }} | |
| # NB: keep this a literal boolean. An expression here (cancel-in-progress: ${{ ... }}) made GitHub | |
| # fail the workflow at startup (startup_failure), so `validate` never reported. | |
| cancel-in-progress: true | |
| jobs: | |
| # Detect which areas a PR touches so the heavy jobs can skip when irrelevant. | |
| # On push to main everything runs regardless (keeps the coverage baseline solid). | |
| changes: | |
| name: changes | |
| # Just actions/checkout + git diff --check + dorny/paths-filter -- no build/test work, and it runs FIRST | |
| # to decide whether validate-code needs to run at all, so gating it behind the same contended self-hosted | |
| # queue it's meant to protect was circular (#2507). | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| backend: ${{ steps.filter.outputs.backend }} | |
| ui: ${{ steps.filter.outputs.ui }} | |
| uiContract: ${{ steps.filter.outputs.uiContract }} | |
| mcp: ${{ steps.filter.outputs.mcp }} | |
| mcpCliHarness: ${{ steps.filter.outputs.mcpCliHarness }} | |
| engine: ${{ steps.filter.outputs.engine }} | |
| miner: ${{ steps.filter.outputs.miner }} | |
| minerTestHarness: ${{ steps.filter.outputs.minerTestHarness }} | |
| rees: ${{ steps.filter.outputs.rees }} | |
| observability: ${{ steps.filter.outputs.observability }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| - name: Check whitespace | |
| run: git diff --check | |
| - name: Filter changed paths | |
| id: filter | |
| uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 | |
| with: | |
| filters: | | |
| backend: | |
| - 'src/**' | |
| - 'test/**' | |
| - 'vitest*.config.ts' | |
| - 'tsconfig*.json' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| - 'scripts/**' | |
| - 'migrations/**' | |
| - '.github/workflows/**' | |
| - 'wrangler.jsonc' | |
| - 'worker-configuration.d.ts' | |
| observability: | |
| - 'grafana/dashboards/**' | |
| - 'prometheus/rules/**' | |
| # The UI's own app/extension code -- triggers the FULL toolchain (lint/typecheck/test/build). | |
| # A dependency bump (package.json/package-lock.json) stays here too since it can break the UI | |
| # build or types in ways only that full toolchain would catch. | |
| ui: | |
| - 'apps/loopover-ui/**' | |
| - 'apps/loopover-miner-ui/**' | |
| - 'apps/loopover-extension/**' | |
| - 'apps/loopover-miner-extension/**' | |
| - 'packages/loopover-ui-kit/**' | |
| - 'scripts/build-extension.mjs' | |
| - 'scripts/build-miner-extension.mjs' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| # Backend changes that can drift the OpenAPI contract the UI type-checks against, but say | |
| # nothing about the UI app's OWN code -- only the lightweight drift check needs to run, not | |
| # ui:lint/typecheck/test/build (#ci-scope). If a backend PR's change IS UI-relevant, regenerating | |
| # `apps/loopover-ui/public/openapi.json` per CONTRIBUTING.md lands a change under `ui` above, | |
| # which correctly re-triggers the full toolchain. | |
| uiContract: | |
| - 'src/**' | |
| - 'scripts/write-ui-openapi.ts' | |
| mcp: | |
| # Self-contained package: build is `node --check` on its own files | |
| # and the pack check only inspects the tarball. Root src/ cannot | |
| # affect it, so it is intentionally NOT a trigger here. | |
| - 'packages/loopover-mcp/**' | |
| - 'scripts/check-mcp-package.mjs' | |
| - 'package-lock.json' | |
| engine: | |
| - 'packages/loopover-engine/**' | |
| - 'package-lock.json' | |
| miner: | |
| - 'packages/loopover-miner/**' | |
| - 'scripts/check-miner-package.mjs' | |
| - 'package-lock.json' | |
| # 6 of the 7 MCP CLI-cluster test files, and ONLY these 6, are truly self-contained w.r.t. root | |
| # src/**: verified by direct-import inspection that test/unit/mcp-cli-*.test.ts (5 files) and | |
| # their shared test/unit/support/mcp-cli-harness.ts import nothing but node:* builtins + vitest, | |
| # and mcp-discovery.test.ts spawns packages/loopover-mcp/bin/loopover-mcp.js as a real | |
| # subprocess via StdioClientTransport -- none of the 6 ever load root src/ in-process. This mirrors | |
| # the `mcp` filter's own established trust boundary above (a self-contained package build). They are | |
| # NOT self-contained w.r.t. packages/loopover-engine/**, though: loopover-mcp now has a real | |
| # dependency on @loopover/engine (isTestFile/isCodeFile), which the bin subprocess and | |
| # local-branch.js load at runtime -- hence that path below, alongside the mcp package's own. | |
| # test/unit/mcp-output-schemas.test.ts is DELIBERATELY NOT in this filter: unlike the other 6, it | |
| # imports src/mcp/server.ts in-process, and server.ts alone directly imports ~40 other src/ modules | |
| # (src/github/app.ts, src/signals/slop.ts, src/settings/autonomy.ts, src/orb/analytics.ts, and | |
| # most of src/services/** + src/signals/**), so its real dependency surface is practically all of | |
| # `backend`. An earlier version of this filter hand-picked a handful of "the src files that affect | |
| # it" for that file too -- that guess missed dozens of server.ts's actual direct imports, which was | |
| # a reachable CI blind spot (a backend change outside the guessed list could regress MCP server | |
| # behavior with zero test rerun to catch it). Enumerating that surface by hand is not safely | |
| # possible, so mcp-output-schemas.test.ts is simply never excluded -- it always runs whenever | |
| # `backend` does, at the cost of keeping its slice of the suite's runtime unconditional. | |
| mcpCliHarness: | |
| - 'vitest*.config.ts' | |
| - 'tsconfig*.json' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| - 'scripts/**' | |
| - 'migrations/**' | |
| - '.github/workflows/**' | |
| - 'packages/loopover-mcp/**' | |
| - 'packages/loopover-engine/**' | |
| - 'test/helpers/**' | |
| - 'test/unit/mcp-cli-*.test.ts' | |
| - 'test/unit/mcp-discovery.test.ts' | |
| - 'test/unit/support/mcp-cli-harness.ts' | |
| rees: | |
| - 'review-enrichment/**' | |
| - '.github/workflows/ci.yml' | |
| # Both miner-package test files are self-contained w.r.t. root src/**, the same trust boundary as | |
| # mcpCliHarness above: check-miner-package.test.ts only spawns scripts/check-miner-package.mjs as a | |
| # real subprocess (node:child_process + vitest, nothing else), and miner-calibration-types.test.ts | |
| # only imports the scaffolded packages/loopover-miner/lib/calibration.js -- neither ever loads | |
| # root src/ in-process. Mirrors mcpCliHarness's filter shape for the same reason (tooling/config | |
| # changes that could plausibly affect how these tests run, plus the miner package + the test files | |
| # themselves), swapping in the miner-specific package and test files. | |
| minerTestHarness: | |
| - 'vitest*.config.ts' | |
| - 'tsconfig*.json' | |
| - 'package.json' | |
| - 'package-lock.json' | |
| - 'scripts/**' | |
| - 'migrations/**' | |
| - '.github/workflows/**' | |
| - 'packages/loopover-miner/**' | |
| - 'test/unit/check-miner-package.test.ts' | |
| - 'test/unit/miner-calibration-types.test.ts' | |
| # Path-aware validation. Keep this as one job so a PR uses one dependency | |
| # 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 | |
| needs: changes | |
| # draft != true also gates push runs correctly: github.event.pull_request is unset there, so the | |
| # property access evaluates to null, and null != true is true. | |
| if: ${{ github.event_name == 'push' || (github.event.pull_request.draft != true && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.rees == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.observability == 'true')) }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| env: | |
| VITE_LOOPOVER_API_ORIGIN: https://api.loopover.ai | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| # Shallow (default depth): this job no longer uploads to Codecov -- that moved to validate-tests | |
| # (#ci-shard-coverage) -- so it has no reason to fetch full history anymore. | |
| 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 | |
| # actions/checkout wipes node_modules (git clean -ffdx) on every run regardless of the self-hosted | |
| # runner's own persistence, and npm ci always deletes+reinstalls node_modules by design -- so | |
| # neither the runner nor npm ci gives node_modules any real cross-run reuse on its own. This | |
| # explicit restore/save pair (via GitHub's own cache service, not local disk) fills that gap: an | |
| # exact manifest+lockfile match skips npm ci entirely. Keep fork/trusted keys separate even though | |
| # both run on ubuntu-latest: fork PRs get read-only cache tokens, so a fork-keyed entry can never | |
| # actually be written; trusted PRs keep their reusable cache without crossing trust boundaries. | |
| - name: Restore node_modules cache | |
| id: node-modules-cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: | | |
| node_modules | |
| apps/loopover-ui/node_modules | |
| # hashFiles('.nvmrc') matters as much as the package manifests and lockfile: a Node bump | |
| # with no lockfile change would otherwise still hit and silently reuse node_modules whose | |
| # native addons (sharp, workerd, fsevents) were compiled against the OLD Node's ABI. The | |
| # manifests matter too because npm ci validates package.json/package-lock.json consistency | |
| # and runs lifecycle scripts from package.json; a package.json-only change must not skip it. | |
| 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 | |
| # Placed immediately after install (not as an automatic post-job hook) so a cache is only ever | |
| # saved once npm ci has actually succeeded -- a job that fails here never reaches this step, so a | |
| # broken/partial node_modules can never get written to the cache for a future run to inherit. | |
| - 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/loopover-ui/node_modules | |
| key: ${{ steps.node-modules-cache.outputs.cache-primary-key }} | |
| # apps/loopover-ui/.source (fumadocs-mdx codegen -- the collections/* alias | |
| # apps/loopover-ui/tsconfig.json depends on) lives outside node_modules and is normally produced by | |
| # npm ci's own postinstall. A node_modules cache hit above skips npm ci entirely, so .source/ would | |
| # otherwise never exist on a cache-hit run. Regenerate it explicitly and unconditionally: cheap, | |
| # deterministic, and a harmless no-op re-run of the same postinstall on a cache miss. | |
| - name: Generate fumadocs content-collection sources | |
| run: npm --workspace @loopover/ui run postinstall | |
| - name: Lint workflows | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run actionlint | |
| - name: Check migrations | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run db:migrations:check | |
| # Guards against a DIFFERENT drift class than db:migrations:check's own column-collision check (#2551, | |
| # which only catches two migration FILES independently adding the same column): src/db/schema.ts's | |
| # DECLARED shape silently drifting from what migrations/ ACTUALLY produces when replayed (#2565). | |
| - name: Schema-vs-migrations drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run db:schema-drift:check | |
| # Guards against the same staleness-drift class as db:migrations:check/ui:openapi:check: two PRs that | |
| # each independently add a wrangler.jsonc binding can both pass CI in isolation, then merge sequentially | |
| # and leave a stale committed worker-configuration.d.ts with zero prior gate signal (#2557). | |
| - name: cf-typegen drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run cf-typegen:check | |
| # Same drift class as cf-typegen/schema-drift above, just a different generated artifact: this file's | |
| # firstReference line numbers point into src/selfhost/ai.ts, so any backend change that shifts those | |
| # lines (not just one that adds/removes an env var) can make it stale. Was previously enforced ONLY by | |
| # the local `npm run test:ci` aggregate script, never by this workflow -- went stale twice in one day | |
| # (2026-07-04) with zero CI signal until someone happened to run the full local script. Gated on `ui` | |
| # too, not just `backend`: the generated file itself lives under apps/loopover-ui/**, so a PR that | |
| # hand-edits it (or a docs page docs:drift-check below cross-checks) without touching backend source | |
| # would otherwise never re-run this check. | |
| - name: Selfhost env-reference drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run selfhost:env-reference:check | |
| # Same generated-artifact-drift class, extracted from src/github/commands.ts's command catalogs (#3046). | |
| # Also local-only until now; same backend-or-ui gating rationale as the step above. | |
| - name: Command reference drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run command-reference:check | |
| # Cross-checks docs pages against source-of-truth enumerable surfaces (feature flags, commands, gate | |
| # modes) -- see the script's own header comment. Also local-only until now; gated on `ui` as well as | |
| # `backend` since the docs pages it validates are themselves UI-side content that can drift on their | |
| # own (a docs-only edit that renames/drops a documented flag/command/gate-mode) with no backend change | |
| # to trigger a re-check otherwise. | |
| - name: Docs drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run docs:drift-check | |
| - name: Validate observability configs | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.observability == 'true' }} | |
| env: | |
| NODE_OPTIONS: "" | |
| run: node scripts/validate-observability-configs.mjs | |
| # Runs ahead of Typecheck AND "Test with coverage" (#ci-engine-build-order): src/mcp/find-opportunities.ts | |
| # (root backend, since #2281/#3985) imports packages/loopover-miner/lib/opportunity-fanout.js -- | |
| # committed, pre-built JS -- which itself imports @loopover/engine. That package's dist/ is | |
| # gitignored and only exists after this build step, so ANY backend typecheck or test run needs it built | |
| # first, not just a PR that touches packages/loopover-engine/** directly (this step's original, too- | |
| # narrow trigger). Originally ran after coverage (broke test:coverage on PRs that didn't touch the | |
| # engine) and after Typecheck (broke a real, non-vi.mock `import type` from the package specifier with | |
| # TS2307 in CI while passing locally against a stale leftover dist/ -- first hit by PR #5082). Now ahead | |
| # of both. | |
| - name: Build engine package | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run build --workspace @loopover/engine | |
| # .tsbuildinfo mutates every run (tsc's own incremental state), unlike node_modules above which is | |
| # immutable per lockfile -- so this needs the run_id-suffixed-key + restore-keys-prefix pattern (always | |
| # creates a new cache entry to save into, restore falls back to the most recent matching prefix) rather | |
| # than the hit-or-miss pattern node_modules uses. Measured ~13.5s cold vs ~3.6s warm locally; tsc | |
| # verifies each file's content hash before trusting cached state, so a fresh checkout's reset mtimes | |
| # can't cause a false cache hit that would mask a real type error. | |
| - name: Restore TypeScript incremental build cache | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: .tsbuildinfo | |
| key: tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}-${{ github.run_id }} | |
| restore-keys: | | |
| tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}- | |
| - name: Typecheck | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run typecheck | |
| - name: Save TypeScript incremental build cache | |
| if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }} | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: .tsbuildinfo | |
| key: tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}-${{ github.run_id }} | |
| - name: Worker runtime tests | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run test:workers | |
| # loopover-mcp now depends on @loopover/engine for real (isTestFile/isCodeFile), so a | |
| # PR that only touches packages/loopover-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 | |
| - name: Miner deployment docs audit | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }} | |
| run: npm run test:miner-deployment-docs-audit | |
| # 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 | |
| # review-enrichment's node:test suite runs against the built dist/, so its real coverage was invisible | |
| # to Codecov (which only ever saw vitest's src/** lcov). `npm run rees:coverage` re-runs that suite under | |
| # c8 (v8 coverage) and remaps the dist hits back to review-enrichment/src/** through the build's source | |
| # maps, producing an lcov Codecov can gate. The authoritative pass/fail for REES stays the uninstrumented | |
| # step above: c8's instrumentation inflates that suite's timing-sensitive linear-time/ReDoS-guard | |
| # assertions past their (uninstrumented) budgets, so a non-zero child exit here is expected -- this | |
| # harvest only needs to emit lcov, and a genuinely broken suite is already caught above (and would show | |
| # as ~0% patch anyway). The verify step below fails CI if no report lands. | |
| - name: REES coverage | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }} | |
| run: npm run rees:coverage || true | |
| - name: Verify REES coverage report exists | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }} | |
| run: | | |
| if [ ! -s review-enrichment/coverage/lcov.info ]; then | |
| echo "::error title=REES coverage::review-enrichment/coverage/lcov.info is missing or empty" | |
| exit 1 | |
| fi | |
| # Separate `rees` flag so codecov/patch actually gates review-enrichment/** changes -- before this the | |
| # package was in no coverage report at all, so a REES-only PR got a vacuous patch check. Same trusted vs. | |
| # fork-tokenless split and override_* handling as the shard uploads in validate-tests below. | |
| - name: Upload REES coverage to Codecov | |
| if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && github.event.pull_request.head.repo.fork != true }} | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| token: ${{ secrets.CODECOV_TOKEN }} | |
| files: ./review-enrichment/coverage/lcov.info | |
| flags: rees | |
| 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: true | |
| - name: Upload REES coverage to Codecov (fork PR tokenless) | |
| if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && github.event.pull_request.head.repo.fork == true }} | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 | |
| with: | |
| files: ./review-enrichment/coverage/lcov.info | |
| flags: rees | |
| 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: true | |
| - 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/loopover-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 | |
| # loopover-ui and loopover-miner-ui both import compiled output from this shared package | |
| # (packages/loopover-ui-kit/dist), which is gitignored like every other workspace package's | |
| # dist -- so it does not exist on a fresh checkout until something builds it. Same class of gap | |
| # as "Build engine package" above; runs before UI lint/typecheck/tests/build all in this same | |
| # job, so building it here once covers all four. | |
| - name: Build UI-kit package | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run ui:kit:build | |
| # apps/loopover-ui's fumadocs-mdx virtual modules (collections/browser, collections/server, imported by | |
| # docs-client-loader.tsx/docs-source.ts) are gitignored generated output, normally produced by npm ci's | |
| # own `postinstall` hook -- but "Install dependencies" above is SKIPPED on a node_modules cache hit (the | |
| # cached path never includes this output), so a warm-cache run reaches UI lint/typecheck/tests/build with | |
| # no collections output at all and fails with "Cannot find module 'collections/browser'". Same class of | |
| # gap as "Build UI-kit package" above (a required, gitignored artifact absent on a fresh/cached | |
| # checkout); runs unconditionally here so cache hit or miss makes no difference. | |
| - name: Generate docs content collections | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run postinstall --workspace @loopover/ui | |
| - 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 | |
| - name: Extension lint | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run extension:lint && npm run miner-extension:lint | |
| - name: Extension typecheck | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run extension:typecheck && npm run miner-extension:typecheck | |
| # `npm run ui:build` also regenerates apps/loopover-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 run miner-extension:build && npm --workspace @loopover/ui run build | |
| # Bundle-size regression tracking for the deployed marketing/product site (loopover-ui only -- | |
| # loopover-miner-ui is a self-hosted operator dashboard with no build step in this job at all, see | |
| # "UI build" above). Fork-excluded (no secrets.CODECOV_TOKEN on a fork PR, same boundary as the | |
| # coverage-upload steps below) and non-blocking (continue-on-error): unlike coverage, nothing gates | |
| # merges on bundle size, so an upload hiccup must never fail CI. Uses @codecov/bundle-analyzer's | |
| # standalone CLI against the built dist/client directly rather than a bundler-plugin hook -- | |
| # @codecov/vite-plugin's latest release doesn't support this repo's Vite 8 yet (peer range caps at | |
| # 6.x; this repo's Vite 8 runs on rolldown, not classic rollup, so @codecov/rollup-plugin doesn't fit | |
| # either) -- revisit wiring the plugin in once upstream catches up. | |
| - name: Upload UI bundle stats to Codecov | |
| if: ${{ (github.event_name == 'push' || needs.changes.outputs.ui == 'true') && github.event.pull_request.head.repo.fork != true }} | |
| continue-on-error: true | |
| env: | |
| CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | |
| run: npm run bundle-analysis --workspace @loopover/ui | |
| # 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=<i>/<n>, 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 | |
| # Also runs on REES-only changes: Codecov holds codecov/patch until `after_n_builds` (6) coverage uploads | |
| # land (codecov.yml), and REES-only PRs don't otherwise touch `backend`, so without the shards a REES PR | |
| # would upload only its single `rees` report and the patch status would never post -- the same vacuous | |
| # check this issue fixes. Running the shards guarantees the 6 uploads so Codecov posts a real patch verdict | |
| # that now includes the review-enrichment/** diff (covered by the `rees` flag from validate-code). | |
| # Draft PRs still skip the whole fan-out (#6448), so the REES trigger only applies to ready PRs. | |
| if: ${{ github.event_name == 'push' || (github.event.pull_request.draft != true && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.rees == 'true')) }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3, 4, 5, 6] | |
| env: | |
| VITE_LOOPOVER_API_ORIGIN: https://api.loopover.ai | |
| 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/loopover-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/loopover-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 @loopover/engine | |
| - name: Prepare test reports dir | |
| run: mkdir -p reports/junit | |
| - name: Test with coverage (shard ${{ matrix.shard }}/6) | |
| id: coverage | |
| env: | |
| # 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 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' }} | |
| SKIP_MINER_TEST_HARNESS: ${{ github.event_name == 'pull_request' && needs.changes.outputs.minerTestHarness != 'true' }} | |
| run: | | |
| EXCLUDE_ARGS=() | |
| if [ "$SKIP_MCP_CLI_HARNESS" = "true" ]; then | |
| echo "Skipping the self-contained MCP CLI-harness tests (no packages/loopover-mcp-relevant paths changed)." | |
| EXCLUDE_ARGS+=( | |
| --exclude "test/unit/mcp-cli-*.test.ts" | |
| --exclude "test/unit/mcp-discovery.test.ts" | |
| ) | |
| fi | |
| if [ "$SKIP_MINER_TEST_HARNESS" = "true" ]; then | |
| echo "Skipping the self-contained miner-package tests (no packages/loopover-miner-relevant paths changed)." | |
| EXCLUDE_ARGS+=( | |
| --exclude "test/unit/check-miner-package.test.ts" | |
| --exclude "test/unit/miner-calibration-types.test.ts" | |
| ) | |
| fi | |
| npm run test:coverage -- --maxWorkers=4 --shard=${{ matrix.shard }}/6 --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 (shard ${{ matrix.shard }}/6)." | |
| 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() }} | |
| run: | | |
| if [ ! -s coverage/lcov.info ]; then | |
| echo "::error title=Coverage::coverage/lcov.info is missing or empty" | |
| exit 1 | |
| fi | |
| # Consumed by validate-tests-merge to re-check the global coverage threshold against all 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.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 }} | |
| override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }} | |
| # 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 -- 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() && 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 }} | |
| override_pr: ${{ github.event.pull_request.number }} | |
| fail_ci_if_error: true | |
| # Test results are useful Codecov annotations, not the coverage gate. Keep | |
| # 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.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() && 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 | |
| # Re-checks vitest.config.ts's global 90% coverage threshold against all 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] | |
| # Same REES trigger as validate-tests: when shards ran for a REES-only PR, merge their reports too. | |
| if: ${{ github.event_name == 'push' || (github.event.pull_request.draft != true && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.rees == '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: | | |
| node_modules | |
| apps/loopover-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: | |
| 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 | |
| # audit workflow, so one upstream CVE never blocks unrelated PRs. | |
| security: | |
| name: security | |
| if: ${{ github.event_name == 'pull_request' && github.event.pull_request.draft != true }} | |
| # actions/dependency-review-action needs no self-hosted toolchain/cache (checkout + a lockfile diff), so | |
| # this always runs on ubuntu-latest -- keeping it self-hosted for same-repo PRs only competed for the | |
| # scarce self-hosted pool with validate-code, the one job that actually benefits from it (#2501). | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| - name: Dependency review | |
| uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 | |
| with: | |
| fail-on-severity: moderate | |
| comment-summary-in-pr: on-failure | |
| # Single required status check. Branch protection points at "validate"; this | |
| # 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, 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). | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 2 | |
| steps: | |
| - name: All required jobs passed | |
| if: ${{ !(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) }} | |
| run: echo "All required CI jobs passed (path-filtered jobs reported skipped, which is OK)." | |
| - name: A required job failed | |
| if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} | |
| run: | | |
| echo "::error title=CI::A required CI job failed or was cancelled." | |
| echo "Job results: ${{ join(needs.*.result, ', ') }}" | |
| exit 1 |