fix(review): guarantee newlineChunks forward progress under high overlap #13668
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 }} | |
| discoveryIndex: ${{ steps.filter.outputs.discoveryIndex }} | |
| 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 }} | |
| # `git diff --check` with no args compares the working tree against the index -- on this checkout's | |
| # default shallow, single-commit fetch they're always identical immediately after checkout, so this | |
| # unconditionally passed regardless of what the PR's actual diff contained (a false-green: the step | |
| # promised a whitespace check that never fired). Fetch just the one comparison SHA (still shallow, | |
| # cheap) and diff against it directly instead. On push (github.event.before can be the null SHA for | |
| # a branch's first push -- not reachable for `main` itself, but kept defensive) or when the fetch | |
| # target isn't resolvable, skip rather than fail the whole pipeline over a best-effort check. | |
| - name: Check whitespace | |
| run: | | |
| BASE_SHA="${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}" | |
| if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then | |
| echo "No comparison SHA available (e.g. branch's first push) -- skipping." | |
| exit 0 | |
| fi | |
| git fetch --depth=1 origin "$BASE_SHA" | |
| git diff --check "$BASE_SHA" HEAD | |
| - 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/**' | |
| # .github/actions/** (composite actions, e.g. setup-workspace) is workflow-adjacent | |
| # infrastructure the same way .github/workflows/** above is -- without this, a PR editing | |
| # ONLY a composite action file (touching nothing else backend matches) would skip both | |
| # "Lint workflows" and "Lint composite actions" below entirely, with zero CI signal on a | |
| # broken edit until the next push-triggered full run. | |
| - '.github/actions/**' | |
| - 'wrangler.jsonc' | |
| - 'worker-configuration.d.ts' | |
| - '.loopover.yml' | |
| - '.release-please-manifest.json' | |
| - 'release-please-config.json' | |
| 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' | |
| # No dedicated build/pack script (unlike mcp/engine/miner above) -- discovery-index is a normal | |
| # vitest-covered workspace package (packages/discovery-index/src/**/*.ts is in vitest.config.ts's | |
| # coverage.include), so this filter only needs to feed validate-code's overall gate + validate-tests | |
| # below, not a standalone job. | |
| discoveryIndex: | |
| - 'packages/discovery-index/**' | |
| - '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.discoveryIndex == '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: | |
| # 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. | |
| - name: Checkout | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 | |
| with: | |
| ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} | |
| # See .github/actions/setup-workspace for what this covers (npmrc, Node, node_modules cache) -- | |
| # the checkout above stays a separate, explicit step since a local `uses: ./path` action needs | |
| # the repo already on disk to even find its own action.yml. | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| # apps/loopover-ui/.source (fumadocs-mdx codegen) is regenerated by "Generate docs content | |
| # collections" below, gated on push||ui==true -- nothing between here and there reads it (docs-drift, | |
| # env-reference, manifest/engine-parity/branding-drift checks all read raw source files directly, and | |
| # the root Typecheck step's tsconfig.json excludes apps/loopover-ui entirely), so it doesn't need to | |
| # be regenerated unconditionally this early too (a prior version of this step duplicated that one, | |
| # added independently and unconditionally before either author noticed the other already existed). | |
| - name: Lint workflows | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run actionlint | |
| # actionlint (above) does not cover .github/actions/**/action.yml at all -- confirmed a genuine, | |
| # long-standing upstream limitation (rhysd/actionlint#46 and #401, open since 2021), not something | |
| # fixable by a different invocation: even the raw actionlint binary, run directly with no wrapper, | |
| # treats any file it's given as a workflow and errors on runs/inputs/outputs as unexpected | |
| # top-level keys. This validates composite action files against GitHub's own official | |
| # action-metadata JSON Schema instead -- real structural validation, not the full expression- | |
| # context linting actionlint does for workflows, which genuinely doesn't exist anywhere for | |
| # action.yml files. | |
| - name: Lint composite actions | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run lint:composite-actions | |
| - 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 | |
| # Miner/AMS twin of the selfhost check above -- same generated-artifact-drift class, same | |
| # local-only-until-now gap (an `env.SOMETHING` read added under packages/loopover-miner/** | |
| # with no CI signal that packages/loopover-miner/docs/env-reference.md and | |
| # apps/loopover-ui/src/lib/ams-env-reference.ts had gone stale). Confirmed harmful live: this | |
| # exact gap let a stale env-reference block an unrelated MCP npm release, since the release | |
| # workflow was the only place this check ever ran. Gated on `ui` too, same reason as its sibling: | |
| # the generated UI-side file lives under apps/loopover-ui/**. Also gated on `engine`: | |
| # DEFAULT_SOURCE_ROOTS in generate-env-reference.mjs explicitly includes | |
| # packages/loopover-engine/src/miner (the coding-agent driver's env vars live there, not under | |
| # packages/loopover-miner itself), so an engine-only PR touching that directory used to skip this | |
| # check entirely. | |
| - name: Miner env-reference drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.engine == 'true' }} | |
| run: npm run miner: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. Also gated on `engine`, unlike its two siblings above (`Selfhost | |
| # env-reference drift check`/`Command reference drift check`): this script's readFileSync reads | |
| # packages/loopover-engine/src/focus-manifest.ts directly to cross-check FocusManifest fields against | |
| # .loopover.yml.example, a dependency the other two generated-artifact-drift checks don't share, so | |
| # deliberately NOT kept uniform with them here (test/unit/ci-generated-artifact-drift-checks.test.ts | |
| # asserts backend||ui is a SUBSET of every condition, not that all three are byte-identical). | |
| - name: Docs drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.engine == 'true' }} | |
| run: npm run docs:drift-check | |
| # Cross-checks the bundled fallback YAML in src/config/loopover-repo-focus-manifest.ts against the | |
| # real root .loopover.yml -- see the script's own header comment. Same local-only-until-now gap as | |
| # the drift checks above: nothing in this workflow previously ran it, so the two could silently | |
| # diverge with zero CI signal. .loopover.yml itself is now part of the `backend` path filter above | |
| # specifically so an edit to ONLY that file still re-triggers this job. | |
| - name: Manifest drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }} | |
| run: npm run manifest:drift-check | |
| # Mechanical drift tripwire for the hand-duplicated src/{review,settings,signals} <-> loopover-engine | |
| # twin files, plus a version-skew check on the installed @loopover/engine (#4260). Same | |
| # local-only-until-now gap as the drift checks above. Also gated on `miner`: the reverse-direction | |
| # version pin this script checks, packages/loopover-miner/expected-engine.version, lives under the | |
| # miner package, not engine or backend -- a PR editing only that one pin file used to skip its own | |
| # consistency check entirely. | |
| - name: Engine-parity drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' }} | |
| run: npm run engine-parity:drift-check | |
| # Guards against the "gittensory" branding creeping back into runtime source after the LoopOver | |
| # rebrand -- see the script's own header comment (#6786 is the concrete incident this was written | |
| # for). Scoped broadly (src/** plus every workspace package's bin/lib/src/scripts dirs), so it's | |
| # gated broadly to match; same local-only-until-now gap as the drift checks above. Also gated on | |
| # `discoveryIndex`: BRANDING_DRIFT_PATHSPECS includes "packages/*/src/**/*.ts", which matches | |
| # packages/discovery-index/src/**. | |
| - name: Branding drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.discoveryIndex == 'true' }} | |
| run: npm run branding-drift:check | |
| # .release-please-manifest.json is release-please's own state file -- it normally keeps this in | |
| # sync itself when its generated Release PR merges, but the documented human-override path for | |
| # a stuck release tag (publish-mcp.yml's own header comment) bumps package.json directly with | |
| # no way to touch release-please's state. That gap went uncaught with zero CI signal, then | |
| # surfaced days later as release-please re-proposing an already-published version as a brand-new | |
| # Release PR, which fails on publish (#7086/#7087). `.release-please-manifest.json` and | |
| # `release-please-config.json` are now part of the `backend` path filter above so an edit to | |
| # either alone still re-triggers this job. | |
| - name: Release-please manifest sync check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run release-manifest:sync:check | |
| # Not gated on `backend`: read the full script -- it only ever reads grafana/dashboards/*.json and | |
| # prometheus/rules/alerts.yml, both fully covered by the `observability` filter above, with zero | |
| # reference to any src/, test/, or other backend path. The `backend` clause had no structural | |
| # justification and made this run on every backend PR (the highest-volume PR shape in the repo) for | |
| # nothing. | |
| - name: Validate observability configs | |
| if: ${{ github.event_name == 'push' || 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. | |
| # | |
| # Turborepo (turbo.json) owns the actual cache correctness here (content-hashes @loopover/engine's own | |
| # inputs independently of whatever this restore serves) -- the .turbo/cache restore below is purely an | |
| # accelerator, same accumulating-cache shape as "Restore TypeScript incremental build cache" below (a | |
| # run_id-suffixed save key + prefix restore-keys fallback, not the hit-or-miss node_modules pattern), | |
| # since turbo's cache directory grows additively rather than being wholesale-replaced per lockfile hash. | |
| # This is also what lets each validate-tests shard (which independently needs this same package built) | |
| # skip rebuilding it from scratch when a warm cache from this job or an earlier run is available. | |
| # | |
| # Gate includes mcp/miner (not just backend/engine/ui) even though "Build engine package" below stays | |
| # scoped to backend/engine/ui: "Build MCP"/"Build miner CLI" further down invoke turbo too, and since | |
| # both packages declare @loopover/engine as a package.json dependency, turbo's own ^build resolution | |
| # builds engine as their transitive dependency regardless of whether this explicit step ran. Without | |
| # this wider gate, an mcp- or miner-only PR would still get a correct build (turbo builds it inline | |
| # with no cache to hit) -- just a cold one, and with no save step to persist it either. | |
| # | |
| # The matching "Save Turborepo cache" step is NOT alongside this one -- it's moved all the way down | |
| # to after "UI build" (the last step in this job that invokes turbo), since actions/cache/save takes | |
| # one synchronous snapshot at the moment it runs and every later turbo-routed step in between (Build | |
| # MCP, Build miner CLI, Build UI-kit package, UI lint/typecheck, Extension lint/typecheck, UI build) | |
| # would otherwise write to .turbo/cache AFTER the snapshot already happened, so none of their cache | |
| # entries would ever persist cross-run. Confirmed this was a real gap (not just theoretical) by | |
| # reading the actual step order before moving it. | |
| # | |
| # Key uses a "turbo-code-" prefix, distinct from validate-tests' "turbo-tests-" prefix below, even | |
| # though both jobs cache the same .turbo/cache path and both build @loopover/engine -- they used to | |
| # share one "turbo-" prefix, which meant all 7 jobs in a single run (this one plus 6 validate-tests | |
| # shards) raced for the SAME cache key. actions/cache/save no-ops on a duplicate key within a run (see | |
| # the comment on validate-tests' own Save step below), and a validate-tests shard reaches its Save | |
| # step after only ~6 steps versus this job's ~20+, so a shard almost always won the race -- meaning | |
| # the richer cache this job builds (engine + mcp + miner + ui-kit + ui + extension) was very likely | |
| # silently discarded in favor of a shard's much leaner engine-only save, defeating most of the point | |
| # of the Save-repositioning fix above. Splitting the prefixes gives each job's Restore/Save pair its | |
| # own independent lineage instead of racing. | |
| - name: Restore Turborepo cache | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == 'true' }} | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: .turbo/cache | |
| key: turbo-code-${{ hashFiles('package-lock.json') }}-${{ github.run_id }} | |
| restore-keys: | | |
| turbo-code-${{ hashFiles('package-lock.json') }}- | |
| - 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: npx turbo run build --filter=@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') }}- | |
| # Deliberately NOT routed through Turborepo's //#typecheck task, unlike every other build/lint/ | |
| # typecheck step in this job -- investigated and rejected, not just skipped. test/** (tsconfig.json's | |
| # own "include") reaches via relative imports into six other workspace packages/apps (see turbo.json's | |
| # //#typecheck comment for the full audit), so a turbo.json inputs list correct enough to avoid a | |
| # stale-cache-masks-a-real-error bug here would need to track that sprawling, growing cross-package | |
| # surface by hand. tsc's own --incremental engine (below) already tracks the exact real dependency | |
| # graph natively and precisely, with zero drift risk, and already delivers the actual measured | |
| # speedup (~13.5s cold vs ~3.6s warm) -- wrapping it in turbo would layer a cruder, hand-maintained | |
| # approximation on top of a mechanism that's already strictly more correct for this specific task. | |
| # Gated on engine/mcp/miner too, not just backend: root src/** genuinely imports across those three | |
| # package boundaries (~57 files import @loopover/engine directly; src/mcp/find-opportunities.ts | |
| # imports packages/loopover-miner/lib/opportunity-fanout.js and opportunity-ranker.js; | |
| # src/services/mcp-compatibility.ts imports packages/loopover-mcp/package.json), so a type-only | |
| # regression introduced in any of those three packages, on a PR that touches only that package, used | |
| # to skip this check entirely -- not caught until the push-triggered run on main, after this repo's | |
| # auto-merge gate had already merged it. tsc's own --incremental engine (unaffected by this widening; | |
| # see its own comment two steps up) already tracks the real graph regardless of which trigger fired. | |
| - name: Typecheck | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == '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 }} | |
| # Same engine/mcp/miner gap as Typecheck above, and more consequential here: test/workers/worker- | |
| # runtime.test.ts (vitest.config.ts excludes it from the main "Test with coverage" run below, so | |
| # nothing else in CI runs it) imports src/index and src/api/routes, which themselves import | |
| # find-opportunities.ts (miner) and mcp-compatibility.ts (mcp) -- this is the only test in all of CI | |
| # that boots the real Cloudflare Workers runtime end-to-end, and it used to never run at all on an | |
| # engine/mcp/miner-only PR. | |
| - name: Worker runtime tests | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == '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. | |
| # | |
| # Routed through Turborepo (turbo.json's @loopover/mcp#build, dependsOn ["^build"]) rather than the | |
| # old npm run build:mcp -- turbo resolves @loopover/engine as its topological dependency (mcp's own | |
| # package.json depends on @loopover/engine) and reuses the "Restore Turborepo cache" above, so an | |
| # engine-only PR's already-built engine is never rebuilt a second time here. | |
| - name: Build MCP | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }} | |
| run: npx turbo run build --filter=@loopover/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 | |
| # Invokes turbo.json's @loopover/miner#build:tsc (cache: false, since its committed lib/**/*.js | |
| # output can't be safely restored without risking a stomp of hand-written files) and | |
| # @loopover/miner#build:verify (real caching win: skips re-`node --check`-ing all 121 bin/lib files | |
| # when neither changed) DIRECTLY as their own task names, deliberately NOT via the aggregate | |
| # `@loopover/miner#build` task (which exists for standalone/local `npm run build` callers) -- that | |
| # aggregate's own script is `npm run build:tsc && npm run build:verify`, i.e. it re-invokes both of | |
| # these exact scripts a second time even when turbo already ran them as cached prerequisite tasks | |
| # (declaring a task as a dependsOn prerequisite doesn't replace a parent task's own script body). | |
| # Calling the aggregate here would silently double both the tsc compile and the syntax check on | |
| # every run, negating the caching win this comment describes -- confirmed by reproducing the double | |
| # execution locally before switching to this direct form. This also finally activates the | |
| # build:tsc/build:verify split from Phase 0, which no ci.yml step exercised until now. | |
| - name: Build miner CLI | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.engine == 'true' }} | |
| run: npx turbo run build:tsc build:verify --filter=@loopover/miner | |
| # loopover-miner depends on @loopover/engine for real too (same relationship as loopover-mcp above | |
| # -- packages/loopover-miner/package.json lists it as a dependency), so this and "Build miner CLI" | |
| # above now also trigger on an engine-only PR, matching "Build MCP"/"MCP package check"'s existing | |
| # engine coupling. Pre-existing asymmetry, not introduced by this change -- fixed here while already | |
| # touching these exact lines. | |
| - name: Miner package check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.engine == 'true' }} | |
| run: npm run test:miner-pack | |
| # scripts/check-miner-deployment-docs.mjs reads packages/loopover-engine/src/miner/**/*.ts | |
| # (ENGINE_MINER_DIR) to build the registered-commands list checked against DEPLOYMENT.md, so an | |
| # engine-only PR touching that directory used to skip this audit entirely. | |
| - name: Miner deployment docs audit | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.engine == '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: npx turbo run build --filter=@loopover/ui-kit | |
| # 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 | |
| # Routed through Turborepo -- "Build UI-kit package" above already built ui-kit's dist once for all of | |
| # lint/typecheck/tests/build in this same job (GitHub Actions steps run strictly sequentially), so | |
| # these turbo invocations reuse it without needing @loopover/ui#lint/@loopover/ui-miner#lint to | |
| # declare their own ^build dependsOn (they intentionally don't -- eslint needs no compiled dist). | |
| # Two --filter flags means "either package" (union), not intersection; both packages' tasks run | |
| # even if one fails, so a failing @loopover/ui lint no longer hides an @loopover/ui-miner lint error | |
| # behind it the way the old `&&` chain did -- both surface in one pass. Deliberately NOT changing the | |
| # package.json ui:lint/ui:typecheck scripts themselves, since ui-deploy.yml and CONTRIBUTING.md's | |
| # documented local gate sequence both call those directly and rely on their own ui:kit:build prefix. | |
| - name: UI lint | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npx turbo run lint --filter=@loopover/ui --filter=@loopover/ui-miner | |
| - name: UI typecheck | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npx turbo run typecheck --filter=@loopover/ui --filter=@loopover/ui-miner | |
| # Split into 3 independent steps (was one `&&`-chained step) -- turbo.json deliberately has no `test` | |
| # task for any package (per the plan doc: test orchestration stays out of Turborepo entirely, so | |
| # Codecov's codecov/patch keeps one centralized source of truth), so this can't use the same | |
| # union-`--filter` fix already applied to "UI lint"/"UI typecheck" two steps above. Plain `&&` meant a | |
| # failing @loopover/ui test silently prevented @loopover/ui-miner's and @loopover/miner-extension's | |
| # tests from ever running in that invocation -- same class of failure-masking bug those two steps' | |
| # own comment already describes, just still present here. `!cancelled()` on steps 2/3 (not `always()`) | |
| # matches the pattern already used by "Save Turborepo cache"/"Save TypeScript incremental build | |
| # cache" elsewhere in this file: still run after an earlier step's failure, just not after the job | |
| # was cancelled outright. The job as a whole still fails if any of the three fails. | |
| - name: UI tests (ui) | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npm --workspace @loopover/ui run test | |
| - name: UI tests (ui-miner) | |
| if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} | |
| run: npm --workspace @loopover/ui-miner run test | |
| - name: UI tests (miner-extension) | |
| if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.ui == 'true') }} | |
| run: npm --workspace @loopover/miner-extension run test | |
| # Same rationale and --filter union semantics as "UI lint"/"UI typecheck" above. | |
| - name: Extension lint | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npx turbo run lint --filter=@loopover/extension --filter=@loopover/miner-extension | |
| - name: Extension typecheck | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npx turbo run typecheck --filter=@loopover/extension --filter=@loopover/miner-extension | |
| # `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. apps/loopover-ui's own | |
| # `build` script (vite build) doesn't touch openapi.json either, so routing through Turborepo | |
| # preserves that skip: @loopover/ui#build's dependsOn (turbo.json) is | |
| # ["^build", "@loopover/extension#build", "@loopover/miner-extension#build"] -- the exact same | |
| # extension + miner-extension build pair this step ran explicitly before, now resolved by turbo's | |
| # own graph instead of hand-chained `&&`, plus ^build (ui-kit, already a same-job cache hit from | |
| # "Build UI-kit package" above). Verified locally: deleting the committed-gitignored extension zip | |
| # and running this exact invocation regenerates it correctly (the cross-package outputs edge is | |
| # honored, not just replayed from cache). | |
| - name: UI build | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npx turbo run build --filter=@loopover/ui | |
| # Paired with "Restore Turborepo cache" above (same key, same gate condition) -- deliberately | |
| # positioned here, after every turbo-routed step in this job (Build engine package, Build MCP, Build | |
| # miner CLI, Build UI-kit package, UI lint/typecheck, Extension lint/typecheck, UI build), rather than | |
| # immediately after "Build engine package" the way it was originally: actions/cache/save takes one | |
| # synchronous snapshot at the moment it runs, so anything written to .turbo/cache by a later step | |
| # would never be captured if this ran earlier. `!cancelled()` (not `always()`) still saves whatever | |
| # was successfully built even if an earlier step in this job failed, as long as the job wasn't | |
| # cancelled outright. | |
| - name: Save Turborepo cache | |
| if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.miner == 'true') }} | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: .turbo/cache | |
| key: turbo-code-${{ hashFiles('package-lock.json') }}-${{ github.run_id }} | |
| # 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/mcp/engine/miner/discoveryIndex-only changes: Codecov holds codecov/patch until | |
| # `after_n_builds` (6) coverage uploads land (codecov.yml), and a PR scoped to just one of those | |
| # self-contained packages doesn't otherwise touch `backend`, so without the shards it would upload no | |
| # coverage report at all and codecov/patch would never post -- not even a vacuous pass, no check at | |
| # all, and the required `validate` aggregate treats a skipped job as success (#7318-#7321: 3 | |
| # packages/loopover-miner/lib-only PRs merged this way with validate-tests never running). Running the | |
| # shards guarantees the 6 uploads so Codecov posts a real patch verdict covering whichever package | |
| # actually changed (review-enrichment/** via the `rees` flag from validate-code; packages/loopover-mcp, | |
| # packages/loopover-engine, packages/loopover-miner, and packages/discovery-index via their entries in | |
| # vitest.config.ts's coverage.include, exercised by the root test/** suite these shards already run). | |
| # Draft PRs still skip the whole fan-out (#6448), so these triggers only apply to ready PRs. | |
| if: ${{ github.event_name == 'push' || (github.event.pull_request.draft != true && (needs.changes.outputs.backend == 'true' || needs.changes.outputs.rees == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.discoveryIndex == '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 }} | |
| # See .github/actions/setup-workspace (npmrc, Node, node_modules cache -- same cache key formula | |
| # as validate-code, 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 if | |
| # another job already wrote it). | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| # 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. Same accumulating .turbo/cache | |
| # restore/save pattern as validate-code's pair, but a DIFFERENT key prefix ("turbo-tests-" vs. | |
| # validate-code's "turbo-code-"): these two jobs used to share one plain "turbo-" prefix, which meant | |
| # all 7 jobs in a single run (validate-code plus these 6 shards) raced for the same cache slot, and | |
| # actions/cache/save no-ops on a duplicate key within a run -- see the shard-vs-shard version of that | |
| # same race below. Because a shard reaches its own Save after only ~6 steps versus validate-code's | |
| # ~20+, a shard almost always won that race, meaning validate-code's much richer cache (engine + mcp + | |
| # miner + ui-kit + ui + extension) was very likely discarded every run in favor of a shard's | |
| # engine-only one. Splitting the prefixes gives each job's own lineage instead of racing; within THIS | |
| # prefix, the 6 shards below still race each other for one slot, which is fine since they all save the | |
| # exact same content (only @loopover/engine#build runs in this job) -- whichever shard wins, the | |
| # content is equivalent, unlike the cross-job case this split just fixed. | |
| - name: Restore Turborepo cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: .turbo/cache | |
| key: turbo-tests-${{ hashFiles('package-lock.json') }}-${{ github.run_id }} | |
| restore-keys: | | |
| turbo-tests-${{ hashFiles('package-lock.json') }}- | |
| - name: Build engine package | |
| run: npx turbo run build --filter=@loopover/engine | |
| - name: Save Turborepo cache | |
| if: ${{ !cancelled() }} | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: .turbo/cache | |
| key: turbo-tests-${{ hashFiles('package-lock.json') }}-${{ github.run_id }} | |
| - name: Prepare test reports dir | |
| run: mkdir -p reports/junit | |
| # Restore-only -- this job never saves it, test-timing-refresh.yml is the sole writer, on its own | |
| # schedule (pulling from Codecov's Test Analytics API, which already pools per-test durations | |
| # across every push-to-main run). key never actually matches (no save ever uses this literal | |
| # string); it exists only so the step always falls through to the restore-keys prefix match, | |
| # picking whatever the most recent refresh wrote. A cache MISS here is always safe: see | |
| # scripts/compute-test-shards.mjs's fallback -- it splits evenly across shards when no timing | |
| # data is available for a file (or none at all), the same balance vitest's own --shard already | |
| # gives today, so this can only make shard balance better than today's baseline, never worse. | |
| - name: Restore test timing cache | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: test-timing.json | |
| key: test-timing-unused | |
| restore-keys: | | |
| test-timing- | |
| - 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' }} | |
| # Scoped test selection (#ci-scoped-test-selection): a PR confined to miner/mcp/discoveryIndex -- | |
| # none of backend/rees/engine -- runs vitest's own --changed against origin/main instead of the | |
| # full ~2,900-test suite, since the same 6-shard fan-out this job exists for (see header comment) | |
| # is otherwise massive overkill for a diff touching one file. engine is deliberately EXCLUDED from | |
| # this fast path: it's the one package other code imports through a built, gitignored dist/ that | |
| # --changed's import-graph analysis can't safely trace across a node_modules/package.json-exports | |
| # boundary -- verified by reproducing a real missed regression (editing packages/loopover-engine/ | |
| # src/ directly, --changed failed to select the miner-side wrapper-integration tests that actually | |
| # exercise it). miner/mcp/discoveryIndex don't have that exposure: nothing else in the repo imports | |
| # them as libraries, so their tests are reached via direct same-package imports, verified to trace | |
| # correctly. Every push to main still runs the full unscoped suite unconditionally (this job's own | |
| # top-level `if:` above is unchanged), so a scoped miss is caught within minutes of merge, not | |
| # never. Kill switch: set the repo variable SCOPED_TEST_SELECTION_ENABLED to "false" to force the | |
| # full suite for every PR (e.g. if a gap is ever found) without a revert PR. | |
| SCOPED_TEST_SELECTION: ${{ github.event_name == 'pull_request' && vars.SCOPED_TEST_SELECTION_ENABLED != 'false' && needs.changes.outputs.backend != 'true' && needs.changes.outputs.rees != 'true' && needs.changes.outputs.engine != 'true' && (needs.changes.outputs.miner == 'true' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.discoveryIndex == '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 | |
| SCOPE_ARGS=() | |
| if [ "$SCOPED_TEST_SELECTION" = "true" ]; then | |
| echo "Scoped test selection: PR confined to miner/mcp/discoveryIndex -- running vitest --changed=origin/main instead of the full suite." | |
| # This job's checkout already uses fetch-depth: 0 (full history, for Codecov's merge-base | |
| # resolution), which should make origin/main resolvable already -- fetch it explicitly anyway | |
| # as a cheap, idempotent guarantee rather than relying on that as an assumption. | |
| git fetch --depth=1 origin main | |
| # --coverage.all=false: without it, v8's default "report every coverage.include-matched file | |
| # at 0% even if untouched by this run" would make this shard's upload claim the rest of the | |
| # ~2,900-file suite is 0% covered, polluting Codecov's project trend on every scoped PR. Real | |
| # coverage for files actually exercised is unaffected either way. | |
| SCOPE_ARGS+=(--changed=origin/main --coverage.all=false) | |
| 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[@]}" "${SCOPE_ARGS[@]}" | |
| else | |
| # Duration-aware sharding (#ci-duration-aware-sharding), full-suite case only: vitest's own | |
| # --shard splits by file COUNT alone, no duration awareness -- confirmed via real per-shard CI | |
| # timing data to produce a consistent ~20-30% gap between the slowest and fastest of the 6 | |
| # shards, every run sampled. Deliberately not applied to the scoped-selection branch above: | |
| # that file set isn't known until vitest resolves --changed itself, so a precomputed | |
| # assignment can't cover it without duplicating vitest's own dependency-graph resolution here. | |
| # compute-test-shards.mjs enforces its own hard invariant (the union of all 6 shards' files | |
| # must exactly equal the discovered file set, no file missing or duplicated) and refuses to | |
| # write output at all if that's ever violated, so a bug here fails this step loudly rather | |
| # than silently dropping a test file from CI. | |
| node scripts/compute-test-shards.mjs --shards=6 --timing=test-timing.json --output=shard-assignment.json | |
| mapfile -t SHARD_FILES < <(node -e "console.log(JSON.parse(require('fs').readFileSync('shard-assignment.json','utf8'))['${{ matrix.shard }}'].join('\n'))") | |
| npm run test:coverage -- --maxWorkers=4 "${SHARD_FILES[@]}" --reporter=default --reporter=blob --reporter=junit --outputFile.blob=blob-report/report-${{ matrix.shard }}.blob --outputFile.junit=reports/junit/vitest.xml "${EXCLUDE_ARGS[@]}" | |
| fi | |
| - 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 trigger as validate-tests: whenever shards ran (REES/mcp/engine/miner/discoveryIndex-only PRs | |
| # included), 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' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.miner == 'true' || needs.changes.outputs.discoveryIndex == '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 }} | |
| # 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. save-cache: | |
| # false -- this job only ever reads that cache, it never writes to it (see | |
| # .github/actions/setup-workspace). | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| save-cache: "false" | |
| - 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 |