fix(github): reduce rate-limit retry storms #4847
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: ${{ 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 }} | |
| mcp: ${{ steps.filter.outputs.mcp }} | |
| 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/**' | |
| 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' | |
| 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 | |
| - 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 | |
| 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 | |
| run: npm run test:coverage -- --maxWorkers=100% | |
| - 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: Upload coverage to Codecov | |
| if: ${{ success() && (github.event_name == 'push' || needs.changes.outputs.backend == '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 || '' }} | |
| fail_ci_if_error: false | |
| - name: Upload Vitest results to Codecov | |
| if: ${{ !cancelled() && (github.event_name == 'push' || needs.changes.outputs.backend == '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: 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 | |
| - name: REES build, source-map validation, and tests | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.rees == 'true' }} | |
| run: npm run rees:test | |
| - name: OpenAPI drift check | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| run: npm run ui:openapi:check | |
| - name: UI/MCP version audit | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == '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 | |
| - name: UI build | |
| if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }} | |
| 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: ${{ 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 |