feat(mcp): Adding Apify MCP server #5
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. | |
| # | |
| # SECURITY: this workflow runs PR-controlled actions, workflows, and code. | |
| # Do not add ``secrets: inherit`` or GitHub App credentials here. Trusted | |
| # main-only automation uses protected environments in its own workflows. | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| pull-requests: write # needed by lint (PR comment) + supply-chain review_status | |
| actions: read # needed by osv-scanner (SARIF upload) | |
| security-events: write # needed by osv-scanner (SARIF upload) | |
| 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: 1 | |
| outputs: | |
| python: ${{ steps.classify.outputs.python }} | |
| python_prod: ${{ steps.classify.outputs.python_prod }} | |
| frontend: ${{ steps.classify.outputs.frontend }} | |
| site: ${{ steps.classify.outputs.site }} | |
| scan: ${{ steps.classify.outputs.scan }} | |
| deps: ${{ steps.classify.outputs.deps }} | |
| uv_lock: ${{ steps.classify.outputs.uv_lock }} | |
| npm_lock: ${{ steps.classify.outputs.npm_lock }} | |
| installer: ${{ steps.classify.outputs.installer }} | |
| desktop_updater: ${{ steps.classify.outputs.desktop_updater }} | |
| rust: ${{ steps.classify.outputs.rust }} | |
| docker_meta: ${{ steps.classify.outputs.docker_meta }} | |
| mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }} | |
| ci_review: ${{ steps.classify.outputs.ci_review }} | |
| ci_review_files: ${{ steps.classify.outputs.ci_review_files }} | |
| event_name: ${{ github.event_name }} | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Detect affected areas | |
| id: classify | |
| uses: ./.github/actions/detect-changes | |
| with: | |
| sparse-checkout: scripts/ci/classify_changes.py | |
| sparse-checkout-cone-mode: false | |
| github-token: ${{ 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 | |
| # macOS + Windows lanes. The main `tests` lane above is Linux-only, and | |
| # the OS-marked tests it collects are skipped there by design (see the | |
| # `_OS_MARKS` comment in tests/conftest.py) — this is where they run. | |
| # Same `python` lane gate: if no Python changed, neither runs. | |
| tests-os: | |
| name: OS-specific tests | |
| needs: detect | |
| if: needs.detect.outputs.python == 'true' | |
| uses: ./.github/workflows/tests-os.yml | |
| with: | |
| # The Windows lane spawns the real desktop-update hand-off script | |
| # (tests/test_desktop_update_windows_*.py) only when that surface | |
| # changed; unit-level windows_only tests always run. | |
| desktop_updater: ${{ needs.detect.outputs.desktop_updater == 'true' }} | |
| lint: | |
| name: Python lints | |
| needs: detect | |
| if: needs.detect.outputs.python == 'true' | |
| uses: ./.github/workflows/lint.yml | |
| with: | |
| event_name: ${{ needs.detect.outputs.event_name }} | |
| js-tests: | |
| name: JS & TS checks | |
| needs: detect | |
| if: needs.detect.outputs.frontend == 'true' | |
| uses: ./.github/workflows/js-tests.yml | |
| installer-tests: | |
| name: Installer tests | |
| needs: detect | |
| # Windows-only, and only for PRs that touch install.ps1 or its tests. | |
| if: needs.detect.outputs.installer == 'true' | |
| uses: ./.github/workflows/installer-tests.yml | |
| rust-tests: | |
| name: Rust tests | |
| needs: detect | |
| # Only for PRs that touch a Rust crate. `.rs` is under apps/, so these | |
| # changes used to run the TypeScript matrix and nothing that compiles them. | |
| if: needs.detect.outputs.rust == 'true' | |
| uses: ./.github/workflows/rust-tests.yml | |
| e2e-desktop: | |
| name: Desktop E2E | |
| needs: detect | |
| # python_prod (not python): the Playwright suite exercises the built app | |
| # + `hermes serve` backend, which never import anything under tests/. | |
| # Tests-only PRs (~17% of commits) skip this 5-minute job — the longest | |
| # single job in the workflow — while still running the full pytest lanes. | |
| # | |
| # Re-disabled (Sep 2026): the Sep 1 re-enable is still incredibly flaky. | |
| # Keep this a bare `if: false`. The earlier | |
| # `${{ false && (... || ...) }}` form on this reusable-workflow job made | |
| # GitHub's workflow parser fail at startup ("An unexpected error has | |
| # occurred") — every ci.yaml run repo-wide dispatched 0 jobs from | |
| # 24f5a60ed1 until this line changed. To re-enable, restore: | |
| # if: ${{ needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true' }} | |
| if: false | |
| uses: ./.github/workflows/e2e-desktop.yml | |
| docs-site: | |
| name: Docs Site | |
| needs: detect | |
| if: needs.detect.outputs.site == 'true' | |
| uses: ./.github/workflows/docs-site-checks.yml | |
| history-check: | |
| name: Deny unrelated histories | |
| needs: detect | |
| if: needs.detect.outputs.event_name == 'pull_request' | |
| uses: ./.github/workflows/history-check.yml | |
| contributor-check: | |
| name: Check contributors | |
| needs: detect | |
| if: needs.detect.outputs.python == 'true' | |
| uses: ./.github/workflows/contributor-check.yml | |
| uv-lockfile: | |
| name: Check uv.lock | |
| needs: detect | |
| # Gated: `uv lock --check` re-resolves the whole dependency graph against | |
| # PyPI, so on every PR it spent a network round-trip — and, on a registry | |
| # blip, a blocking red X — for diffs that cannot desync the lockfile | |
| # (docs, frontend, prose). Only pyproject.toml / uv.lock can. A | |
| # `.github/` change still forces it on via the classifier's fail-open. | |
| if: needs.detect.outputs.uv_lock == 'true' | |
| uses: ./.github/workflows/uv-lockfile-check.yml | |
| infographic-check: | |
| name: Check no committed infographics | |
| needs: detect | |
| uses: ./.github/workflows/infographic-check.yml | |
| profile-artifact-check: | |
| name: Profile artifact check | |
| needs: detect | |
| uses: ./.github/workflows/profile-artifact-check.yml | |
| case-collision-check: | |
| name: Check no case-colliding filenames | |
| needs: detect | |
| uses: ./.github/workflows/case-collision-check.yml | |
| 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 | |
| docker-lint: | |
| name: Lint Docker scripts | |
| needs: detect | |
| if: needs.detect.outputs.docker_meta == 'true' | |
| uses: ./.github/workflows/docker-lint.yml | |
| 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') | |
| 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' }} | |
| review-labels: | |
| name: Review label gate | |
| needs: [detect, supply-chain] | |
| if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true') | |
| uses: ./.github/workflows/review-labels.yml | |
| with: | |
| ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} | |
| ci_review_files: ${{ needs.detect.outputs.ci_review_files }} | |
| mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }} | |
| supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }} | |
| osv-scanner: | |
| name: OSV scan | |
| uses: ./.github/workflows/osv-scanner.yml | |
| # ───────────────────────────────────────────────────────────────────── | |
| # 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. | |
| # | |
| # Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so | |
| # the live comment poller can list failed jobs in the PR comment. | |
| # ───────────────────────────────────────────────────────────────────── | |
| all-checks-pass: | |
| name: All required checks pass | |
| needs: | |
| - detect | |
| - tests | |
| - tests-os | |
| - lint | |
| - js-tests | |
| - installer-tests | |
| - rust-tests | |
| - e2e-desktop | |
| - docs-site | |
| - history-check | |
| - contributor-check | |
| - uv-lockfile | |
| - case-collision-check | |
| - lockfile-diff | |
| - docker-lint | |
| - profile-artifact-check | |
| - supply-chain | |
| - review-labels | |
| - osv-scanner | |
| # The image build runs in its own workflow (docker.yml) and reports | |
| # its own check. It was never required here, because it is too slow | |
| # to block a merge. A separate run also stops it from holding this | |
| # run open. That is what blocked ``gh run rerun``. | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| outputs: | |
| needs-json: ${{ steps.evaluate.outputs.needs-json }} | |
| steps: | |
| - name: Evaluate job results | |
| id: evaluate | |
| env: | |
| NEEDS: ${{ toJSON(needs) }} | |
| run: | | |
| echo "$NEEDS" | python3 -c " | |
| import json, sys | |
| needs = json.load(sys.stdin) | |
| # Emit compact {job_name: result} for the comment assembler. | |
| compact = {name: info['result'] for name, info in needs.items()} | |
| print(f'needs-json={json.dumps(compact)}') | |
| with open('$GITHUB_OUTPUT', 'a') as f: | |
| f.write(f'needs-json={json.dumps(compact)}\n') | |
| 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. | |
| # | |
| # The live comment poller dynamically fetches all review-status-* artifacts | |
| # across the orchestrator and sub-workflow runs every cycle, so its link | |
| # points straight at that report. | |
| # ───────────────────────────────────────────────────────────────────── | |
| ci-timings: | |
| name: CI timing report | |
| needs: [all-checks-pass] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Restore baseline cache (PR only) | |
| if: github.event_name == 'pull_request' | |
| uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | |
| 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: | |
| GITHUB_TOKEN: ${{ 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-html | |
| with: | |
| name: ci-timings-report | |
| path: ci-timings-report.html | |
| retention-days: 14 | |
| - name: Build linked review status | |
| if: hashFiles('ci-timings.json') != '' | |
| env: | |
| CI_TIMINGS_REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url }} | |
| run: | | |
| python3 scripts/ci/timings_report.py \ | |
| --from-json ci-timings.json \ | |
| --baseline ci-timings-baseline.json \ | |
| --review-status-out review-status.json \ | |
| --review-status-only | |
| - name: Upload review status | |
| if: hashFiles('review-status.json') != '' | |
| continue-on-error: true | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: review-status-ci-timings | |
| path: review-status.json | |
| retention-days: 14 | |
| - name: Output summary | |
| env: | |
| REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url}} | |
| run: | | |
| { | |
| echo "# CI Timing report" | |
| 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 | |
| with: | |
| path: ci-timings-baseline.json | |
| key: ci-timings-baseline-${{ github.run_id }} |