Skip to content

fix(selfhost): cap historical PR file hydration and cache by head SHA #5724

fix(selfhost): cap historical PR file hydration and cache by head SHA

fix(selfhost): cap historical PR file hydration and cache by head SHA #5724

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
runs-on: ${{ fromJSON((github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && '["ubuntu-latest"]' || '["self-hosted","gittensory"]') }}
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 }}
rees: ${{ steps.filter.outputs.rees }}
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@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
with:
filters: |
backend:
- 'src/**'
- 'test/**'
- 'vitest*.config.ts'
- 'tsconfig*.json'
- 'package.json'
- 'package-lock.json'
- 'scripts/**'
- 'migrations/**'
- '.github/workflows/**'
# 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-extension/**'
- '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'
# 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).
# 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/**'
- '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'
# Path-aware validation. Keep this as one self-hosted job so a PR uses one
# runner slot and one dependency install instead of fanning out into several
# competing installs on the same VPS. Fork PRs still run on GitHub-hosted
# runners because their code is untrusted.
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.rees == 'true' || needs.changes.outputs.ui == 'true' }}
runs-on: ${{ fromJSON((github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && '["ubuntu-latest"]' || '["self-hosted","gittensory"]') }}
timeout-minutes: 45
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: 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 the wiped local disk) fills that
# gap: an exact manifest+lockfile match skips npm ci entirely. Keyed separately per fork/trusted
# (see the runs-on expression above) because self-hosted's Docker image and GitHub's ubuntu-latest
# image are not guaranteed binary-compatible for native modules (sharp, workerd, fsevents, ...) --
# crossing them could load an incompatible native binary. Fork PRs get read-only cache tokens (a
# documented actions/cache behavior), so a "fork"-keyed entry can never actually be written; that's
# fine, it just means fork PRs keep doing a full npm ci exactly as before -- no regression, no risk
# on the highest-stakes (no-retry) population.
- name: Restore node_modules cache
id: node-modules-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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
- name: Typecheck
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run typecheck
- name: Prepare test reports dir
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: mkdir -p reports/junit
- name: Test with coverage
id: coverage
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
env:
VITEST_JUNIT_PATH: reports/junit/vitest.xml
# Push always runs the full suite (keeps the coverage baseline solid); a PR skips only the 6
# self-contained MCP CLI-harness files when their real dependency surface (the mcpCliHarness filter
# above) didn't change. mcp-output-schemas.test.ts is never in the exclude list -- see that filter's
# comment for why it can't be safely narrowed.
SKIP_MCP_CLI_HARNESS: ${{ github.event_name == 'pull_request' && needs.changes.outputs.mcpCliHarness != 'true' }}
# Pinned to the self-hosted runner's actual Docker CPU quota (4), not "100%": Node's os.cpus()
# reports the HOST's full core count inside the container, ignoring the cgroup CPU limit, so
# --maxWorkers=100% oversubscribed the worker pool far past what the runner can actually execute
# in parallel, causing thrashing. A GitHub-hosted fork-PR runner has no such limit, but the
# standard 4-core GitHub runner makes 4 the right number there too.
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
npm run test:coverage -- --maxWorkers=4 "${EXCLUDE_ARGS[@]}"
- name: Test failure guidance
if: ${{ failure() && steps.coverage.conclusion == 'failure' }}
run: |
echo "::error title=Tests::The backend test coverage suite failed."
echo "Coverage itself is gated by Codecov on changed lines (codecov/patch), computed from the complete lcov generated by this job."
echo "Reproduce locally with: 'npm run test:coverage'."
- name: Verify coverage report exists
if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') }}
run: |
if [ ! -s coverage/lcov.info ]; then
echo "::error title=Coverage::coverage/lcov.info is missing or empty"
exit 1
fi
# Direct upload for trusted contexts (push + same-repo PRs), where secrets.CODECOV_TOKEN is
# available.
- name: Upload coverage to Codecov
if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == 'true') && 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
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, so they cannot use the token path above. codecov-action v4+ supports
# tokenless upload for public repos, but its commit auto-detection cannot be trusted here: for
# pull_request events GITHUB_SHA is the ephemeral auto-merge commit, and codecov-cli's fallback to
# recover the real head sha assumes HEAD is that 2-parent merge commit -- our checkout step above
# deliberately checks out github.event.pull_request.head.sha directly (so tests run the contributor's
# actual commit, not a synthetic merge), so HEAD has one parent and that recovery can't fire. Pass
# the same explicit overrides as the trusted upload above so the report attaches to the real PR head,
# not a merge sha GitHub's PR checks list has no reason to display.
#
# override_branch is prefixed with the fork owner (owner:branch) -- per Codecov's own docs, only a
# branch string containing a colon is recognized as "unprotected" and eligible for a tokenless
# upload; a bare branch name looks like it could be a real (possibly protected) branch on the base
# repo and gets rejected with "Token required because branch is protected" even with no token
# configured at all. codecov-cli's own auto-detection never adds this prefix either (verified in its
# source), so this must be supplied explicitly.
- name: Upload coverage to Codecov (fork PR tokenless)
if: ${{ success() && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.fork == true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ./coverage/lcov.info
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_name == 'push' || needs.changes.outputs.backend == 'true') && 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
disable_search: true
override_branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
override_commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
override_pr: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || '' }}
fail_ci_if_error: false
- name: Upload Vitest results to Codecov (fork PR tokenless)
if: ${{ !cancelled() && needs.changes.outputs.backend == 'true' && github.event.pull_request.head.repo.fork == true }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: ./reports/junit/vitest.xml
report_type: test_results
disable_search: true
override_branch: ${{ github.event.pull_request.head.repo.owner.login }}:${{ github.event.pull_request.head.ref }}
override_commit: ${{ github.event.pull_request.head.sha }}
override_pr: ${{ github.event.pull_request.number }}
fail_ci_if_error: false
- name: Worker runtime tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
run: npm run test:workers
- name: Build MCP
if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' }}
run: npm run build:mcp
- name: MCP package check
if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' }}
run: npm run test:mcp-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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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
# Checks apps/gittensory-ui/src' known-latest MCP version string against the published package, so
# its dependency is `ui` (the file it scans) + `mcp` (the package it checks against) -- NOT the
# OpenAPI contract, which this script never reads.
- name: UI/MCP version audit
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' || needs.changes.outputs.mcp == 'true' }}
run: npm run ui:version-audit
- name: UI lint
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run ui:lint
- name: UI typecheck
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run ui:typecheck
- name: UI tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run ui:test
# `npm run ui:build` also regenerates apps/gittensory-ui/public/openapi.json (needed for a
# standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift
# check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this
# step runs, that check already ran and passed in this same job -- the committed spec is
# already byte-identical to what regenerating it here would produce. Run ui:build's other two
# steps directly instead of the aggregate script, skipping that redundant regen.
- name: UI build
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run extension:build && npm --workspace @jsonbored/gittensory-ui run build
# 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' }}
runs-on: ${{ fromJSON((github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && '["ubuntu-latest"]' || '["self-hosted","gittensory"]') }}
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 job and the PR-only dependency review gate so that
# requirement keeps working unchanged.
# Path-filtered jobs report "skipped", which is treated as success.
validate:
name: validate
needs: [changes, validate-code, security]
if: ${{ always() }}
runs-on: ${{ fromJSON((github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && '["ubuntu-latest"]' || '["self-hosted","gittensory"]') }}
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