feat(signals): classify Elixir, Swift, and Gradle lockfiles #4908
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| 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 |