Skip to content

fix(miner): log prompt-injection audit from buildTaskBrief #13661

fix(miner): log prompt-injection audit from buildTaskBrief

fix(miner): log prompt-injection audit from buildTaskBrief #13661

Workflow file for this run

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