feat: add quota_exhausted failure category for sub-tool paid backends (Closes #1489) #775
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 | |
| # Orchestrator workflow. Runs ``detect-changes`` once, then conditionally | |
| # calls the sub-workflows that a PR can actually affect. A final | |
| # ``all-checks-pass`` gate job aggregates results so branch protection only | |
| # needs to require a single check. | |
| # | |
| # Sub-workflows are triggered via ``workflow_call`` and keep their own job | |
| # definitions, matrices, and concurrency settings. They no longer have | |
| # ``push:`` / ``pull_request:`` triggers of their own — everything flows | |
| # through this file. | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment) | |
| actions: read # needed by osv-scanner (SARIF upload) | |
| security-events: write # needed by osv-scanner (SARIF upload) | |
| packages: write # needed by docker build | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| # ───────────────────────────────────────────────────────────────────── | |
| # detect: run the classifier once. Every downstream job reads its outputs | |
| # to decide whether to run. On push/dispatch the classifier fails open | |
| # (all lanes true) so post-merge validation is never weakened. | |
| # ───────────────────────────────────────────────────────────────────── | |
| detect: | |
| name: Detect affected areas | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| outputs: | |
| python: ${{ steps.classify.outputs.python }} | |
| frontend: ${{ steps.classify.outputs.frontend }} | |
| site: ${{ steps.classify.outputs.site }} | |
| scan: ${{ steps.classify.outputs.scan }} | |
| deps: ${{ steps.classify.outputs.deps }} | |
| npm_lock: ${{ steps.classify.outputs.npm_lock }} | |
| docker_meta: ${{ steps.classify.outputs.docker_meta }} | |
| mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }} | |
| ci_review: ${{ steps.classify.outputs.ci_review }} | |
| event_name: ${{ github.event_name }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Detect affected areas | |
| id: classify | |
| uses: ./.github/actions/detect-changes | |
| with: | |
| # Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to | |
| # the built-in read-only token so classification still works there. | |
| github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Lane-gated sub-workflows. Each runs in parallel after detect finishes. | |
| # Skipped workflows (if condition is false) don't spin up runners. | |
| # ───────────────────────────────────────────────────────────────────── | |
| tests: | |
| name: Python tests | |
| needs: detect | |
| if: needs.detect.outputs.python == 'true' | |
| uses: ./.github/workflows/tests.yml | |
| with: | |
| slice_count: 8 | |
| secrets: inherit | |
| lint: | |
| name: Python lints | |
| needs: detect | |
| if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true' | |
| uses: ./.github/workflows/lint.yml | |
| with: | |
| event_name: ${{ needs.detect.outputs.event_name }} | |
| ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} | |
| secrets: inherit | |
| js-tests: | |
| name: JS & TS checks | |
| needs: detect | |
| if: needs.detect.outputs.frontend == 'true' | |
| uses: ./.github/workflows/js-tests.yml | |
| secrets: inherit | |
| docs-site: | |
| name: Docs Site | |
| needs: detect | |
| if: needs.detect.outputs.site == 'true' | |
| uses: ./.github/workflows/docs-site-checks.yml | |
| secrets: inherit | |
| history-check: | |
| name: Deny unrelated histories | |
| needs: detect | |
| if: needs.detect.outputs.event_name == 'pull_request' | |
| uses: ./.github/workflows/history-check.yml | |
| secrets: inherit | |
| contributor-check: | |
| name: Check contributors | |
| needs: detect | |
| if: needs.detect.outputs.python == 'true' | |
| uses: ./.github/workflows/contributor-check.yml | |
| secrets: inherit | |
| uv-lockfile: | |
| name: Check uv.lock | |
| needs: detect | |
| uses: ./.github/workflows/uv-lockfile-check.yml | |
| secrets: inherit | |
| lockfile-diff: | |
| name: package-lock.json diff | |
| needs: detect | |
| if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true' | |
| uses: ./.github/workflows/lockfile-diff.yml | |
| secrets: inherit | |
| docker-lint: | |
| name: Lint Docker scripts | |
| needs: detect | |
| if: needs.detect.outputs.docker_meta == 'true' | |
| uses: ./.github/workflows/docker-lint.yml | |
| secrets: inherit | |
| supply-chain: | |
| name: Supply-chain scan | |
| needs: detect | |
| if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true') | |
| uses: ./.github/workflows/supply-chain-audit.yml | |
| with: | |
| event_name: ${{ needs.detect.outputs.event_name }} | |
| scan: ${{ needs.detect.outputs.scan == 'true' }} | |
| deps: ${{ needs.detect.outputs.deps == 'true' }} | |
| mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }} | |
| secrets: inherit | |
| osv-scanner: | |
| name: OSV scan | |
| uses: ./.github/workflows/osv-scanner.yml | |
| secrets: inherit | |
| # ───────────────────────────────────────────────────────────────────── | |
| # Gate: runs after everything. ``if: always()`` ensures it reports a | |
| # status even when some deps were skipped. Only actual ``failure`` | |
| # results cause it to fail; ``skipped`` is treated as success. | |
| # | |
| # Branch protection should require ONLY this check. | |
| # ───────────────────────────────────────────────────────────────────── | |
| all-checks-pass: | |
| name: All required checks pass | |
| needs: | |
| - tests | |
| - lint | |
| - js-tests | |
| - docs-site | |
| - history-check | |
| - contributor-check | |
| - uv-lockfile | |
| - lockfile-diff | |
| - docker-lint | |
| - supply-chain | |
| - osv-scanner | |
| # docker is NOT orchestrated here: this fork keeps docker.yml as a | |
| # STANDALONE workflow (on: push/pull_request/release, PR-gated publish), | |
| # not a reusable workflow_call job. Upstream's reusable ``docker:`` job | |
| # was intentionally dropped during the #562 catch-up to match the fork. | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Evaluate job results | |
| env: | |
| NEEDS: ${{ toJSON(needs) }} | |
| run: | | |
| echo "$NEEDS" | python3 -c " | |
| import json, sys | |
| needs = json.load(sys.stdin) | |
| failed = [name for name, info in needs.items() if info['result'] == 'failure'] | |
| for name, info in sorted(needs.items()): | |
| result = info['result'] | |
| icon = '✅' if result in ('success', 'skipped') else '❌' | |
| print(f'{icon} {name}: {result}') | |
| if failed: | |
| print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}') | |
| sys.exit(1) | |
| print('All checks passed (or were skipped)') | |
| " | |
| # ───────────────────────────────────────────────────────────────────── | |
| # CI timing report: collect per-job/step durations from the GitHub API, | |
| # cache them on main (as a baseline), and on PRs generate an HTML diff | |
| # report with a gantt chart + per-step breakdown. The report is uploaded | |
| # as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY. | |
| # ───────────────────────────────────────────────────────────────────── | |
| ci-timings: | |
| name: CI timing report | |
| # No `docker` in needs: this fork keeps docker.yml standalone (see the | |
| # all-checks-pass comment above) — upstream's [all-checks-pass, docker] | |
| # would reference a job that doesn't exist here and GitHub rejects the | |
| # whole workflow at parse time. | |
| needs: [all-checks-pass] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Restore baseline cache (PR only) | |
| if: github.event_name == 'pull_request' | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ci-timings-baseline.json | |
| # Prefix-match: exact key will never hit (run_id differs), so | |
| # restore-keys finds the most recent baseline from main. | |
| key: ci-timings-baseline-never-exact | |
| restore-keys: | | |
| ci-timings-baseline- | |
| - name: Collect timings and generate report | |
| env: | |
| # Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to | |
| # the built-in read-only token so the timings API read still works | |
| # there instead of hard-failing this advisory job on every fork PR. | |
| GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} | |
| run: | | |
| python3 scripts/ci/timings_report.py \ | |
| --baseline ci-timings-baseline.json \ | |
| --output ci-timings-report.html \ | |
| --json-out ci-timings.json \ | |
| --summary-out ci-timings-summary.md | |
| - name: Upload HTML report | |
| # Advisory report — artifact-service blips must not fail the job. | |
| continue-on-error: true | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| id: ci-timings-artifact | |
| with: | |
| name: ci-timings-report | |
| path: ci-timings-report.html | |
| retention-days: 14 | |
| archive: false | |
| - name: Output summary | |
| env: | |
| REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}} | |
| run: | | |
| echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY" | |
| echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY" | |
| cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY" | |
| - name: Save baseline cache (main only) | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' | |
| run: | | |
| # Degraded runs (API rate-limited) produce no ci-timings.json — | |
| # skip rather than fail, and never cache an empty baseline. | |
| if [ -f ci-timings.json ]; then | |
| cp ci-timings.json ci-timings-baseline.json | |
| else | |
| echo "No timings JSON this run — skipping baseline update" | |
| fi | |
| - name: Upload baseline to cache (main only) | |
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' && hashFiles('ci-timings-baseline.json') != '' | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ci-timings-baseline.json | |
| key: ci-timings-baseline-${{ github.run_id }} |