Skip to content

docs(miner): add the repo-agnostic capability audit checklist (#5359) #10513

docs(miner): add the repo-agnostic capability audit checklist (#5359)

docs(miner): add the repo-agnostic capability audit checklist (#5359) #10513

Workflow file for this run

name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
concurrency:
# Keep pull requests in one ref-scoped group so newer commits cancel superseded PR runs.
# Add github.sha for push runs so distinct main commits do not cancel each other's validation.
group: ci-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || 'pr' }}
# NB: keep this a literal boolean. An expression here (cancel-in-progress: ${{ ... }}) made GitHub
# fail the workflow at startup (startup_failure), so `validate` never reported.
cancel-in-progress: true
jobs:
# Detect which areas a PR touches so the heavy jobs can skip when irrelevant.
# On push to main everything runs regardless (keeps the coverage baseline solid).
changes:
name: changes
# Just actions/checkout + git diff --check + dorny/paths-filter -- no build/test work, and it runs FIRST
# to decide whether validate-code needs to run at all, so gating it behind the same contended self-hosted
# queue it's meant to protect was circular (#2507).
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
backend: ${{ steps.filter.outputs.backend }}
ui: ${{ steps.filter.outputs.ui }}
uiContract: ${{ steps.filter.outputs.uiContract }}
mcp: ${{ steps.filter.outputs.mcp }}
mcpCliHarness: ${{ steps.filter.outputs.mcpCliHarness }}
engine: ${{ steps.filter.outputs.engine }}
miner: ${{ steps.filter.outputs.miner }}
minerTestHarness: ${{ steps.filter.outputs.minerTestHarness }}
rees: ${{ steps.filter.outputs.rees }}
observability: ${{ steps.filter.outputs.observability }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Check whitespace
run: git diff --check
- name: Filter changed paths
id: filter
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4
with:
filters: |
backend:
- 'src/**'
- 'test/**'
- 'vitest*.config.ts'
- 'tsconfig*.json'
- 'package.json'
- 'package-lock.json'
- 'scripts/**'
- 'migrations/**'
- '.github/workflows/**'
- 'wrangler.jsonc'
- 'worker-configuration.d.ts'
observability:
- 'grafana/dashboards/**'
- 'prometheus/rules/**'
# The UI's own app/extension code -- triggers the FULL toolchain (lint/typecheck/test/build).
# A dependency bump (package.json/package-lock.json) stays here too since it can break the UI
# build or types in ways only that full toolchain would catch.
ui:
- 'apps/gittensory-ui/**'
- 'apps/gittensory-miner-ui/**'
- 'apps/gittensory-extension/**'
- 'packages/gittensory-ui-kit/**'
- 'scripts/build-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/gittensory-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/gittensory-mcp/**'
- 'scripts/check-mcp-package.mjs'
- 'package-lock.json'
engine:
- 'packages/gittensory-engine/**'
- 'package-lock.json'
miner:
- 'packages/gittensory-miner/**'
- 'scripts/check-miner-package.mjs'
- 'package-lock.json'
# 6 of the 7 MCP CLI-cluster test files, and ONLY these 6, are truly self-contained w.r.t. root
# src/**: verified by direct-import inspection that test/unit/mcp-cli-*.test.ts (5 files) and
# their shared test/unit/support/mcp-cli-harness.ts import nothing but node:* builtins + vitest,
# and mcp-discovery.test.ts spawns packages/gittensory-mcp/bin/gittensory-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/gittensory-engine/**, though: gittensory-mcp now has a real
# dependency on @jsonbored/gittensory-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/gittensory-mcp/**'
- 'packages/gittensory-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/gittensory-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/gittensory-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
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.rees == 'true' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.observability == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
VITE_GITTENSORY_API_ORIGIN: https://gittensory-api.aethereal.dev
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# Shallow (default depth): this job no longer uploads to Codecov -- that moved to validate-tests
# (#ci-shard-coverage) -- so it has no reason to fetch full history anymore.
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Neutralize untrusted npm config
run: rm -f .npmrc
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: .nvmrc
cache: npm
# actions/checkout wipes node_modules (git clean -ffdx) on every run regardless of the self-hosted
# runner's own persistence, and npm ci always deletes+reinstalls node_modules by design -- so
# neither the runner nor npm ci gives node_modules any real cross-run reuse on its own. This
# explicit restore/save pair (via GitHub's own cache service, not local disk) fills that gap: an
# exact manifest+lockfile match skips npm ci entirely. Keep fork/trusted keys separate even though
# both run on ubuntu-latest: fork PRs get read-only cache tokens, so a fork-keyed entry can never
# actually be written; trusted PRs keep their reusable cache without crossing trust boundaries.
- name: Restore node_modules cache
id: node-modules-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
apps/gittensory-ui/node_modules
# hashFiles('.nvmrc') matters as much as the package manifests and lockfile: a Node bump
# with no lockfile change would otherwise still hit and silently reuse node_modules whose
# native addons (sharp, workerd, fsevents) were compiled against the OLD Node's ABI. The
# manifests matter too because npm ci validates package.json/package-lock.json consistency
# and runs lifecycle scripts from package.json; a package.json-only change must not skip it.
key: npm-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('package.json', 'apps/*/package.json', 'packages/*/package.json', 'package-lock.json') }}
- name: Install dependencies (retry on transient failures)
if: ${{ steps.node-modules-cache.outputs.cache-hit != 'true' }}
run: |
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --no-fund; then
exit 0
fi
echo "::warning::npm ci failed (attempt ${attempt}/3); retrying in 10s"
sleep 10
done
echo "::error::npm ci failed after 3 attempts"
exit 1
# Placed immediately after install (not as an automatic post-job hook) so a cache is only ever
# saved once npm ci has actually succeeded -- a job that fails here never reaches this step, so a
# broken/partial node_modules can never get written to the cache for a future run to inherit.
- name: Save node_modules cache
if: ${{ steps.node-modules-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
apps/gittensory-ui/node_modules
key: ${{ steps.node-modules-cache.outputs.cache-primary-key }}
- name: Lint workflows
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run actionlint
- name: Check migrations
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run db:migrations:check
# Guards against a DIFFERENT drift class than db:migrations:check's own column-collision check (#2551,
# which only catches two migration FILES independently adding the same column): src/db/schema.ts's
# DECLARED shape silently drifting from what migrations/ ACTUALLY produces when replayed (#2565).
- name: Schema-vs-migrations drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run db:schema-drift:check
# Guards against the same staleness-drift class as db:migrations:check/ui:openapi:check: two PRs that
# each independently add a wrangler.jsonc binding can both pass CI in isolation, then merge sequentially
# and leave a stale committed worker-configuration.d.ts with zero prior gate signal (#2557).
- name: cf-typegen drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run cf-typegen:check
# Same drift class as cf-typegen/schema-drift above, just a different generated artifact: this file's
# firstReference line numbers point into src/selfhost/ai.ts, so any backend change that shifts those
# lines (not just one that adds/removes an env var) can make it stale. Was previously enforced ONLY by
# the local `npm run test:ci` aggregate script, never by this workflow -- went stale twice in one day
# (2026-07-04) with zero CI signal until someone happened to run the full local script. Gated on `ui`
# too, not just `backend`: the generated file itself lives under apps/gittensory-ui/**, so a PR that
# hand-edits it (or a docs page docs:drift-check below cross-checks) without touching backend source
# would otherwise never re-run this check.
- name: Selfhost env-reference drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run selfhost:env-reference:check
# Same generated-artifact-drift class, extracted from src/github/commands.ts's command catalogs (#3046).
# Also local-only until now; same backend-or-ui gating rationale as the step above.
- name: Command reference drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run command-reference:check
# Cross-checks docs pages against source-of-truth enumerable surfaces (feature flags, commands, gate
# modes) -- see the script's own header comment. Also local-only until now; gated on `ui` as well as
# `backend` since the docs pages it validates are themselves UI-side content that can drift on their
# own (a docs-only edit that renames/drops a documented flag/command/gate-mode) with no backend change
# to trigger a re-check otherwise.
- name: Docs drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run docs:drift-check
- name: Validate observability configs
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.observability == 'true' }}
env:
NODE_OPTIONS: ""
run: node scripts/validate-observability-configs.mjs
# Runs ahead of Typecheck AND "Test with coverage" (#ci-engine-build-order): src/mcp/find-opportunities.ts
# (root backend, since #2281/#3985) imports packages/gittensory-miner/lib/opportunity-fanout.js --
# committed, pre-built JS -- which itself imports @jsonbored/gittensory-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/gittensory-engine/** directly (this step's original, too-
# narrow trigger). Originally ran after coverage (broke test:coverage on PRs that didn't touch the
# engine) and after Typecheck (broke a real, non-vi.mock `import type` from the package specifier with
# TS2307 in CI while passing locally against a stale leftover dist/ -- first hit by PR #5082). Now ahead
# of both.
- name: Build engine package
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' || needs.changes.outputs.engine == 'true' || needs.changes.outputs.ui == 'true' }}
run: npm run build --workspace @jsonbored/gittensory-engine
# .tsbuildinfo mutates every run (tsc's own incremental state), unlike node_modules above which is
# immutable per lockfile -- so this needs the run_id-suffixed-key + restore-keys-prefix pattern (always
# creates a new cache entry to save into, restore falls back to the most recent matching prefix) rather
# than the hit-or-miss pattern node_modules uses. Measured ~13.5s cold vs ~3.6s warm locally; tsc
# verifies each file's content hash before trusting cached state, so a fresh checkout's reset mtimes
# can't cause a false cache hit that would mask a real type error.
- name: Restore TypeScript incremental build cache
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .tsbuildinfo
key: tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}-${{ github.run_id }}
restore-keys: |
tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}-
- name: Typecheck
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run typecheck
- name: Save TypeScript incremental build cache
if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .tsbuildinfo
key: tsbuildinfo-${{ hashFiles('tsconfig.json', 'package-lock.json') }}-${{ github.run_id }}
- name: Worker runtime tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run test:workers
# gittensory-mcp now depends on @jsonbored/gittensory-engine for real (isTestFile/isCodeFile), so a
# PR that only touches packages/gittensory-engine/** must also rebuild + pack-check the mcp package,
# not just the mcp filter's own (deliberately narrower) path list.
- name: Build MCP
if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }}
run: npm run build:mcp
- name: MCP package check
if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' || needs.changes.outputs.engine == 'true' }}
run: npm run test:mcp-pack
- name: Build miner CLI
if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }}
run: npm run build:miner
- name: Miner package check
if: ${{ github.event_name == 'push' || needs.changes.outputs.miner == 'true' }}
run: npm run test:miner-pack
# review-enrichment is not an npm workspace member (its own package-lock.json), so it needs its own
# cache entry -- same restore/save-after-success pattern and fork/trusted key split as the root
# install above, for the same reasons.
- name: Restore review-enrichment node_modules cache
id: rees-node-modules-cache
if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }}
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: review-enrichment/node_modules
# Same Node-version and manifest guards as the root cache key above -- REES runs on the same
# pinned .nvmrc and has its own package.json lifecycle/install validation.
key: npm-rees-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('review-enrichment/package.json', 'review-enrichment/package-lock.json') }}
- name: REES install
if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && steps.rees-node-modules-cache.outputs.cache-hit != 'true' }}
run: npm run rees:install
- name: Save review-enrichment node_modules cache
if: ${{ (github.event_name == 'push' || needs.changes.outputs.rees == 'true') && steps.rees-node-modules-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: review-enrichment/node_modules
key: ${{ steps.rees-node-modules-cache.outputs.cache-primary-key }}
- name: REES build, source-map validation, and tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }}
run: npm --prefix review-enrichment test
- name: OpenAPI drift check
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }}
run: npm run ui:openapi:check
# #2556: RepositorySettingsSchema (hand-authored Zod) can silently drift from the actual
# RepositorySettings TS type -- the OpenAPI drift check above only verifies the generated spec
# matches the Zod schema, never that the schema matches the type the API actually serializes.
- name: OpenAPI settings-parity check
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.uiContract == 'true' }}
run: npm run ui:openapi:settings-parity
# Checks apps/gittensory-ui/src' known-latest MCP version string against the published package, so
# its dependency is `ui` (the file it scans) + `mcp` (the package it checks against) -- NOT the
# OpenAPI contract, which this script never reads.
- name: UI/MCP version audit
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' }}
run: npm run ui:version-audit
# gittensory-ui and gittensory-miner-ui both import compiled output from this shared package
# (packages/gittensory-ui-kit/dist), which is gitignored like every other workspace package's
# dist -- so it does not exist on a fresh checkout until something builds it. Same class of gap
# as "Build engine package" above; runs before UI lint/typecheck/tests/build all in this same
# job, so building it here once covers all four.
- name: Build UI-kit package
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run ui:kit:build
- name: UI lint
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run ui:lint
- name: UI typecheck
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run ui:typecheck
- name: UI tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run ui:test
# `npm run ui:build` also regenerates apps/gittensory-ui/public/openapi.json (needed for a
# standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift
# check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this
# step runs, that check already ran and passed in this same job -- the committed spec is
# already byte-identical to what regenerating it here would produce. Run ui:build's other two
# steps directly instead of the aggregate script, skipping that redundant regen.
- name: UI build
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build
# The full-suite coverage run, sharded (#ci-shard-coverage). This alone was ~9-10 of the ~11 minutes a
# typical backend PR spent in validate-code, because vitest schedules whole test FILES atomically to
# workers -- one unsharded job with --maxWorkers=4 (a standard runner's real CPU budget) has no way to
# use more than 4-way parallelism no matter how many files exist. Splitting the vitest invocation itself
# across N parallel GitHub-hosted jobs (vitest's own --shard=<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
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6]
env:
VITE_GITTENSORY_API_ORIGIN: https://gittensory-api.aethereal.dev
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# Full history so Codecov can resolve the merge base for patch coverage.
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Neutralize untrusted npm config
run: rm -f .npmrc
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: .nvmrc
cache: npm
# Same cache key formula as validate-code's own restore/save pair, so a lockfile-unchanged PR gets a
# cache hit here too and skips npm ci entirely. Concurrent jobs racing to save the same key is safe:
# actions/cache/save no-ops (warns, doesn't fail) if another job already wrote that exact key.
- name: Restore node_modules cache
id: node-modules-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
apps/gittensory-ui/node_modules
key: npm-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('package.json', 'apps/*/package.json', 'packages/*/package.json', 'package-lock.json') }}
- name: Install dependencies (retry on transient failures)
if: ${{ steps.node-modules-cache.outputs.cache-hit != 'true' }}
run: |
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --no-fund; then
exit 0
fi
echo "::warning::npm ci failed (attempt ${attempt}/3); retrying in 10s"
sleep 10
done
echo "::error::npm ci failed after 3 attempts"
exit 1
- name: Save node_modules cache
if: ${{ steps.node-modules-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
apps/gittensory-ui/node_modules
key: ${{ steps.node-modules-cache.outputs.cache-primary-key }}
# Any backend test run needs the engine package's dist/ built first -- see the identical step's
# comment in validate-code (#ci-engine-build-order) for why.
- name: Build engine package
run: npm run build --workspace @jsonbored/gittensory-engine
- name: Prepare test reports dir
run: mkdir -p reports/junit
- name: Test with coverage (shard ${{ matrix.shard }}/6)
id: coverage
env:
# Disables vitest.config.ts's own global 90% threshold check for THIS per-shard invocation -- a
# single shard only exercises part of the tree, so it would always false-fail. The global-threshold
# "catastrophe net" this disables is NOT dropped from CI, though: validate-tests-merge (below) merges
# all shards' coverage via vitest's own --mergeReports and re-checks the threshold against that
# merged (whole-suite) total, without COVERAGE_NO_THRESHOLDS set.
COVERAGE_NO_THRESHOLDS: "true"
# Same self-contained-test-file narrowing as the pre-sharding job had -- see the `mcpCliHarness`/
# `minerTestHarness` filter comments in the `changes` job above for the full rationale.
SKIP_MCP_CLI_HARNESS: ${{ github.event_name == 'pull_request' && needs.changes.outputs.mcpCliHarness != 'true' }}
SKIP_MINER_TEST_HARNESS: ${{ github.event_name == 'pull_request' && needs.changes.outputs.minerTestHarness != 'true' }}
run: |
EXCLUDE_ARGS=()
if [ "$SKIP_MCP_CLI_HARNESS" = "true" ]; then
echo "Skipping the self-contained MCP CLI-harness tests (no packages/gittensory-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/gittensory-miner-relevant paths changed)."
EXCLUDE_ARGS+=(
--exclude "test/unit/check-miner-package.test.ts"
--exclude "test/unit/miner-calibration-types.test.ts"
)
fi
npm run test:coverage -- --maxWorkers=4 --shard=${{ matrix.shard }}/6 --reporter=default --reporter=blob --reporter=junit --outputFile.blob=blob-report/report-${{ matrix.shard }}.blob --outputFile.junit=reports/junit/vitest.xml "${EXCLUDE_ARGS[@]}"
- name: Test failure guidance
if: ${{ failure() && steps.coverage.conclusion == 'failure' }}
run: |
echo "::error title=Tests::The backend test coverage suite failed (shard ${{ matrix.shard }}/6)."
echo "Coverage itself is gated by Codecov on changed lines (codecov/patch), computed from all shards' merged lcov."
echo "Reproduce locally with: 'npm run test:coverage' (unsharded, runs the whole suite)."
- name: Verify coverage report exists
if: ${{ success() }}
run: |
if [ ! -s coverage/lcov.info ]; then
echo "::error title=Coverage::coverage/lcov.info is missing or empty"
exit 1
fi
# Consumed by validate-tests-merge to re-check the global coverage threshold against all shards
# combined -- see this job's own header comment.
- name: Upload coverage blob report
if: ${{ success() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-blob-shard-${{ matrix.shard }}
path: blob-report/report-${{ matrix.shard }}.blob
retention-days: 1
# Direct upload for trusted contexts (push + same-repo PRs). Multiple shards' uploads for the same
# commit/PR are additive in Codecov -- see this job's own header comment.
- name: Upload coverage to Codecov
if: ${{ success() && github.event.pull_request.head.repo.fork != true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: shard-${{ matrix.shard }}
disable_search: true
override_branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}
# Coverage upload is part of the hard gate: upload or service errors
# should fail CI instead of allowing a PR to merge without patch data.
fail_ci_if_error: true
# Fork PRs run without secrets -- see the identical step's comment in the old single-job validate-code
# (preserved in git history) for the full tokenless-upload rationale (branch-prefix requirement, sha
# override, etc). Condition dropped the push-or-backend check since this job's own `if:` already
# covers it.
- name: Upload coverage to Codecov (fork PR tokenless)
if: ${{ success() && github.event.pull_request.head.repo.fork == true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ./coverage/lcov.info
flags: shard-${{ matrix.shard }}
disable_search: true
override_branch: ${{ github.event.pull_request.head.repo.owner.login }}:${{ github.event.pull_request.head.ref }}
override_commit: ${{ github.event.pull_request.head.sha }}
override_pr: ${{ github.event.pull_request.number }}
fail_ci_if_error: true
# Test results are useful Codecov annotations, not the coverage gate. Keep
# their upload non-blocking so a JUnit ingestion hiccup does not fail CI
# after the tests and hard coverage upload have already passed.
- name: Upload Vitest results to Codecov
if: ${{ !cancelled() && github.event.pull_request.head.repo.fork != true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./reports/junit/vitest.xml
report_type: test_results
flags: shard-${{ matrix.shard }}
disable_search: true
override_branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}
fail_ci_if_error: false
- name: Upload Vitest results to Codecov (fork PR tokenless)
if: ${{ !cancelled() && github.event.pull_request.head.repo.fork == true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ./reports/junit/vitest.xml
report_type: test_results
flags: shard-${{ matrix.shard }}
disable_search: true
override_branch: ${{ github.event.pull_request.head.repo.owner.login }}:${{ github.event.pull_request.head.ref }}
override_commit: ${{ github.event.pull_request.head.sha }}
override_pr: ${{ github.event.pull_request.number }}
fail_ci_if_error: false
# Re-checks vitest.config.ts's global 90% coverage threshold against all shards MERGED -- each shard
# above deliberately disables that check for itself (COVERAGE_NO_THRESHOLDS=true), since a single shard's
# partial view would always false-fail it. This is the "loose catastrophe net" (e.g. a deleted test file)
# vitest.config.ts's own comment describes; Codecov's patch gate (changed-lines only) doesn't cover a
# whole-repo regression outside the diff, so this is what actually restores that backstop for CI, using
# vitest's own --mergeReports against each shard's uploaded blob report.
validate-tests-merge:
name: validate-tests-merge
needs: [changes, validate-tests]
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Neutralize untrusted npm config
run: rm -f .npmrc
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: .nvmrc
cache: npm
# Same cache key as validate-code/validate-tests -- a cache hit here is the common case since those
# jobs run concurrently and one of them usually wins the race to populate it first.
- name: Restore node_modules cache
id: node-modules-cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
node_modules
apps/gittensory-ui/node_modules
key: npm-${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'fork' || 'trusted' }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('package.json', 'apps/*/package.json', 'packages/*/package.json', 'package-lock.json') }}
- name: Install dependencies (retry on transient failures)
if: ${{ steps.node-modules-cache.outputs.cache-hit != 'true' }}
run: |
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --no-fund; then
exit 0
fi
echo "::warning::npm ci failed (attempt ${attempt}/3); retrying in 10s"
sleep 10
done
echo "::error::npm ci failed after 3 attempts"
exit 1
- name: Download all shards' blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
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' }}
# 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