Skip to content

feat(signals): classify Elixir, Swift, and Gradle lockfiles #4908

feat(signals): classify Elixir, Swift, and Gradle lockfiles

feat(signals): classify Elixir, Swift, and Gradle lockfiles #4908

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: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
timeout-minutes: 5
outputs:
backend: ${{ steps.filter.outputs.backend }}
ui: ${{ steps.filter.outputs.ui }}
mcp: ${{ steps.filter.outputs.mcp }}
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/**'
ui:
- 'apps/gittensory-ui/**'
- 'apps/gittensory-extension/**'
- 'src/**'
- 'scripts/write-ui-openapi.ts'
- 'scripts/build-extension.mjs'
- 'package.json'
- 'package-lock.json'
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'
# Fast-failing checks first: workflow lint + typecheck.
lint:
name: lint
needs: changes
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
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: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies (retry on transient failures)
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: Lint workflows
run: npm run actionlint
- name: Check migrations
run: npm run db:migrations:check
- name: Typecheck
run: npm run typecheck
# Unit/integration suite + coverage, sharded across parallel runners to cut the
# critical-path wall-clock. Each shard publishes its PARTIAL lcov as a build
# artifact; the `coverage-upload` job merges all 3 and uploads to Codecov ONCE,
# so Codecov computes codecov/patch a single time on the complete report instead
# of flapping FAILURE->SUCCESS as each shard's partial upload arrived. vitest's
# local 90% backstop is disabled here (COVERAGE_NO_THRESHOLDS) because a single
# shard only exercises part of the tree; the backstop still runs on the full
# local `npm run test:coverage`.
test:
name: test
needs: changes
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
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
- name: Install dependencies (retry on transient failures)
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: Prepare test reports dir
run: mkdir -p reports/junit
- name: Test with coverage (shard ${{ matrix.shard }}/3)
id: coverage
env:
COVERAGE_NO_THRESHOLDS: "1"
VITEST_JUNIT_PATH: reports/junit/vitest-shard-${{ matrix.shard }}.xml
run: npm run test:coverage -- --shard=${{ matrix.shard }}/3
- name: Test failure guidance
if: ${{ failure() && steps.coverage.conclusion == 'failure' }}
run: |
echo "::error title=Tests::A test in shard ${{ matrix.shard }}/3 failed."
echo "Coverage itself is gated by Codecov on changed lines (codecov/patch), computed from the merged shards."
echo "Reproduce locally with the full suite: 'npm run test:coverage' (no sharding)."
- name: Upload partial coverage artifact (shard ${{ matrix.shard }}/3)
# A passing shard writes coverage/lcov.info; a FAILING shard writes none
# (vitest coverage.reportOnFailure defaults to false). Gate on success() so a
# red shard skips this (and fails the job → fails `validate`), and use
# if-no-files-found:error so a green shard always ships exactly one lcov and
# the merge job can assert all 3 arrived. The single Codecov upload happens
# in the coverage-upload job below.
if: ${{ success() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-lcov-shard-${{ matrix.shard }}
path: ./coverage/lcov.info
if-no-files-found: error
retention-days: 1
- name: Upload Vitest results to Codecov
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./reports/junit/vitest-shard-${{ matrix.shard }}.xml
report_type: test_results
fail_ci_if_error: false
# Merge the 3 shard lcovs into one complete report and upload to Codecov ONCE.
# The shards no longer upload coverage directly: that made Codecov score
# codecov/patch on a partially-merged subset of shards as each upload arrived, so
# the status flapped FAILURE->SUCCESS until all 3 landed. A single upload of the
# merged report means Codecov computes patch exactly once on complete data. This
# job inherits `test`'s path filter and only runs when ALL shards passed, so a
# non-backend PR (which skips `test`) emits no codecov/patch context at all — and
# codecov/patch is intentionally NOT a required branch-protection context, so an
# absent status never strands the review gate at pending.
coverage-upload:
name: coverage-upload
needs: [changes, test]
if: ${{ (github.event_name == 'push' || needs.changes.outputs.backend == 'true') && needs.test.result == 'success' }}
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
timeout-minutes: 10
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: Download shard coverage artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: coverage-lcov-shard-*
path: shard-coverage
- name: Collect the shard lcovs into stable paths
# download-artifact nests each artifact under its own directory, and
# upload-artifact's internal layout for a single file is version-dependent, so
# find the lcovs wherever they landed and copy them to fixed names for an
# explicit Codecov upload. Assert exactly 3 — fewer means an upload/runner
# anomaly (needs.test.result already gated this job to all-shards-green), and a
# hard CI failure (one-shot auto-close, recoverable) beats silently scoring
# codecov/patch on a subset.
run: |
mkdir -p merged-lcov
n=0
for f in $(find shard-coverage -name lcov.info | sort); do
n=$((n + 1))
cp "$f" "merged-lcov/shard-${n}.info"
done
echo "Collected ${n} shard lcov file(s)."
if [ "${n}" -ne 3 ]; then
echo "::error title=Coverage::Expected 3 shard lcov files, found ${n}."
exit 1
fi
- name: Upload all shard coverage to Codecov in one report
# All 3 partial lcovs in a SINGLE codecov-action invocation = ONE upload;
# Codecov merges them server-side before computing codecov/patch (it "always
# merges report data" and holds notifications until merge completes), so the
# status is computed once on the complete report — eliminating the partial
# flap that 3 separate per-shard uploads caused. fail_ci_if_error stays false
# so a Codecov outage cannot fail CI / auto-close an honest PR (codecov/patch
# is simply absent, which the gate treats as not-a-FAILURE).
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./merged-lcov/shard-1.info,./merged-lcov/shard-2.info,./merged-lcov/shard-3.info
fail_ci_if_error: false
# Worker-pool runtime tests (separate vitest config); split out of `test` so it
# runs in parallel instead of serially after the coverage run.
workers:
name: workers
needs: changes
if: ${{ github.event_name == 'push' || needs.changes.outputs.backend == 'true' }}
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
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: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies (retry on transient failures)
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: Worker runtime tests
run: npm run test:workers
# MCP package build + publishable-package smoke check.
mcp:
name: mcp
needs: changes
if: ${{ github.event_name == 'push' || needs.changes.outputs.mcp == 'true' }}
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
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: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: .nvmrc
cache: npm
- name: Install dependencies (retry on transient failures)
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: Build MCP
run: npm run build:mcp
- name: MCP package check
run: npm run test:mcp-pack
# UI checks: each split into its own step for legible failures.
ui:
name: ui
needs: changes
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
timeout-minutes: 15
env:
VITE_GITTENSORY_API_ORIGIN: https://gittensory-api.aethereal.dev
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
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
- name: Install dependencies (retry on transient failures)
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: OpenAPI drift check
run: npm run ui:openapi:check
- name: UI/MCP version audit
run: npm run ui:version-audit
- name: UI lint
run: npm run ui:lint
- name: UI typecheck
run: npm run ui:typecheck
- name: UI tests
run: npm run ui:test
- name: UI build
run: npm run ui: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: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
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 fan-out jobs so that requirement keeps working unchanged.
# Path-filtered jobs report "skipped", which is treated as success.
validate:
name: validate
needs: [changes, lint, test, coverage-upload, workers, mcp, ui, security]
if: ${{ always() }}
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true) && 'ubuntu-latest' || 'self-hosted' }}
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