From 05c896cf524991f95c34ce73d2cbe985b5e0558f Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 23 Jun 2026 09:13:19 -0400 Subject: [PATCH] ci: refactor paths & clones ci: centralize path-gating behind single orchestrator + all-checks-pass gate Replace the scattered per-workflow detect-changes pattern with a single ci.yml orchestrator that runs the classifier once, then conditionally calls sub-workflows via workflow_call based on lane outputs. A final all-checks-pass job (if: always()) aggregates all results so branch protection only needs to require one check. Changes: - New .github/workflows/ci.yml orchestrator (detect + conditional calls + all-checks-pass gate) - Extend classify_changes.py with scan/deps/mcp_catalog lanes, absorbing supply-chain-audit's internal changes job - Update detect-changes/action.yml to expose the new lane outputs - Convert all 10 PR-gated sub-workflows to workflow_call-only triggers, removing their push/pull_request triggers and per-step detect-changes guards (gating now happens at the orchestrator level) - lint.yml + supply-chain-audit.yml receive event_name as a workflow_call input to replace github.event_name (which is "workflow_call" inside called workflows) - supply-chain-audit.yml: remove internal changes job + *-gate jobs (orchestrator handles gating, booleans arrive as inputs) - contributor-check.yml: remove internal filter step - Update test_classify_changes.py for 6-lane output + new supply-chain test cases --- .github/actions/detect-changes/action.yml | 44 ++++--- .github/workflows/ci.yml | 146 ++++++++++++++++++++++ .github/workflows/contributor-check.yml | 21 +--- .github/workflows/docker-lint.yml | 14 +-- .github/workflows/docs-site-checks.yml | 25 +--- .github/workflows/history-check.yml | 6 +- .github/workflows/lint.yml | 56 ++------- .github/workflows/osv-scanner.yml | 24 +--- .github/workflows/supply-chain-audit.yml | 133 ++++++-------------- .github/workflows/tests.yml | 43 +------ .github/workflows/typecheck.yml | 31 +---- .github/workflows/uv-lockfile-check.yml | 15 +-- scripts/ci/classify_changes.py | 41 +++++- tests/ci/test_classify_changes.py | 49 ++++++-- 14 files changed, 315 insertions(+), 333 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 6a67530d7f2..268b0aa103c 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -1,13 +1,9 @@ name: Detect affected areas description: >- - Classify a PR's changed files into CI work categories (python, frontend, - site) so heavy jobs can skip work they cannot be affected by. Outputs are - always "true" on push/dispatch events and fail open (everything "true") when - the diff cannot be computed — a skipped category must never be a false - negative. - -# The caller must check out the repo with `fetch-depth: 0` BEFORE using this -# action, so both the PR base and head commits are present for `git diff`. + Classify a PR's changed files into CI work lanes (python, frontend, site, + scan, deps, mcp_catalog) so the orchestrator can conditionally call only + the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch + events and fail open (everything "true") when the diff cannot be computed. outputs: python: @@ -16,9 +12,21 @@ outputs: frontend: description: Run the TypeScript typecheck matrix + desktop build. value: ${{ steps.classify.outputs.frontend }} + docker_meta: + description: Docker setup and meta files have changed. + value: ${{ steps.classify.outputs.docker_meta }} site: description: Build the Docusaurus docs site. value: ${{ steps.classify.outputs.site }} + scan: + description: Run the supply-chain critical-pattern scanner. + value: ${{ steps.classify.outputs.scan }} + deps: + description: Check pyproject.toml dependency upper bounds. + value: ${{ steps.classify.outputs.deps }} + mcp_catalog: + description: Require MCP catalog security review label. + value: ${{ steps.classify.outputs.mcp_catalog }} runs: using: composite @@ -27,22 +35,28 @@ runs: id: classify shell: bash env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} EVENT_NAME: ${{ github.event_name }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail + # Only pull_request events are gated. Other events (push, release, # dispatch) leave CHANGED empty, so the classifier fails open and every - # lane runs — post-merge / on-demand validation is never weakened. + # lane runs. Post-merge / on-demand validation is never weakened. if [ "$EVENT_NAME" = "pull_request" ]; then - # Three-dot diff = what the PR introduces vs its merge base, matching - # how a reviewer reads it. An uncomputable diff (shallow clone, etc.) - # yields an empty list, which the classifier also fails open on. - CHANGED="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}" || true)" + # Use the compare endpoint with the pinned base/head SHAs from the + # event payload instead of the "current PR files" endpoint. The SHAs + # are frozen at trigger time, so the file list is deterministic even + # if the PR receives a new push between trigger and detect. + CHANGED="$(gh api \ + --paginate \ + "repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \ + --jq '.files[].filename' || true)" fi + echo "Changed files:" printf '%s\n' "${CHANGED:-(none)}" - # Caller already checked out the repo, so the classifier is at its - # repo-relative path. It is the single source of the fail-open default. printf '%s\n' "${CHANGED:-}" | python3 scripts/ci/classify_changes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..cb8e2840a04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,146 @@ +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: + branches: [main] + 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) + +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: + runs-on: ubuntu-latest + 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 }} + docker_meta: ${{ steps.classify.outputs.docker_meta }} + mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }} + event_name: ${{ github.event_name }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Detect affected areas + id: classify + uses: ./.github/actions/detect-changes + + # ───────────────────────────────────────────────────────────────────── + # Lane-gated sub-workflows. Each runs in parallel after detect finishes. + # Skipped workflows (if condition is false) don't spin up runners. + # ───────────────────────────────────────────────────────────────────── + tests: + needs: detect + if: needs.detect.outputs.python == 'true' + uses: ./.github/workflows/tests.yml + + lint: + needs: detect + if: needs.detect.outputs.python == 'true' + uses: ./.github/workflows/lint.yml + with: + event_name: ${{ needs.detect.outputs.event_name }} + + typecheck: + needs: detect + if: needs.detect.outputs.frontend == 'true' + uses: ./.github/workflows/typecheck.yml + + docs-site: + needs: detect + if: needs.detect.outputs.site == 'true' + uses: ./.github/workflows/docs-site-checks.yml + + history-check: + needs: detect + if: needs.detect.outputs.event_name == 'pull_request' + uses: ./.github/workflows/history-check.yml + + contributor-check: + needs: detect + if: needs.detect.outputs.python == 'true' + uses: ./.github/workflows/contributor-check.yml + + uv-lockfile: + needs: detect + uses: ./.github/workflows/uv-lockfile-check.yml + + docker-lint: + needs: detect + if: needs.detect.outputs.docker_meta == 'true' + uses: ./.github/workflows/docker-lint.yml + + supply-chain: + needs: detect + if: 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' }} + + osv-scanner: + needs: detect + 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. + # ───────────────────────────────────────────────────────────────────── + all-checks-pass: + name: All required checks pass + needs: + - tests + - lint + - typecheck + - docs-site + - history-check + - contributor-check + - uv-lockfile + - docker-lint + - supply-chain + - osv-scanner + if: always() + runs-on: ubuntu-latest + steps: + - name: Evaluate job results + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" | python3 -c " + import json, sys + results = json.load(sys.stdin) + failed = [r for r in results if r == 'failure'] + if failed: + print(f'::error::{len(failed)} job(s) failed') + sys.exit(1) + print('All checks passed (or were skipped)') + " diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 23266931a69..b7c3db7f827 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -1,11 +1,8 @@ name: Contributor Attribution Check on: - # No paths filter — the job must always run so the required check - # reports a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - branches: [main] + workflow_call: + permissions: contents: read @@ -17,21 +14,7 @@ jobs: with: fetch-depth: 0 # Full history needed for git log - - name: Check if relevant files changed - id: filter - run: | - BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true) - if [ -n "$CHANGED" ]; then - echo "run=true" >> "$GITHUB_OUTPUT" - else - echo "run=false" >> "$GITHUB_OUTPUT" - echo "No Python files changed, skipping attribution check." - fi - - name: Check for unmapped contributor emails - if: steps.filter.outputs.run == 'true' run: | # Get the merge base between this PR and main MERGE_BASE=$(git merge-base origin/main HEAD) diff --git a/.github/workflows/docker-lint.yml b/.github/workflows/docker-lint.yml index 631add200ad..c01bf31f5c4 100644 --- a/.github/workflows/docker-lint.yml +++ b/.github/workflows/docker-lint.yml @@ -11,19 +11,7 @@ name: Docker / shell lint # activate script doesn't exist at lint time. on: - push: - branches: [main] - paths: - - Dockerfile - - docker/** - - .hadolint.yaml - - .github/workflows/docker-lint.yml - - # No paths filter — the job must always run so the required check - # reports a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - branches: [main] + workflow_call: permissions: contents: read diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml index 3ffe51ec744..705f2171e5c 100644 --- a/.github/workflows/docs-site-checks.yml +++ b/.github/workflows/docs-site-checks.yml @@ -1,13 +1,7 @@ name: Docs Site Checks on: - # No paths filter — the job must always run so the required check - # reports a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - branches: [main] - - workflow_dispatch: + workflow_call: permissions: contents: read @@ -17,55 +11,38 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # full history so detect-changes can diff base...head - - # Skip the site build on PRs that touch nothing the docs site is built - # from (website/, skills/, optional-skills/). The job still reports green - # (only the steps below are skipped) so the required check never hangs. - - name: Detect affected areas - id: changes - uses: ./.github/actions/detect-changes - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - if: steps.changes.outputs.site == 'true' with: node-version: 22 cache: npm cache-dependency-path: website/package-lock.json - name: Install website dependencies - if: steps.changes.outputs.site == 'true' uses: ./.github/actions/retry with: command: npm ci working-directory: website - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - if: steps.changes.outputs.site == 'true' with: python-version: "3.11" - name: Install ascii-guard - if: steps.changes.outputs.site == 'true' uses: ./.github/actions/retry with: command: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3 - name: Extract skill metadata for dashboard - if: steps.changes.outputs.site == 'true' run: python3 website/scripts/extract-skills.py - name: Regenerate per-skill docs pages + catalogs - if: steps.changes.outputs.site == 'true' run: python3 website/scripts/generate-skill-docs.py - name: Lint docs diagrams - if: steps.changes.outputs.site == 'true' run: npm run lint:diagrams working-directory: website - name: Build Docusaurus - if: steps.changes.outputs.site == 'true' run: npm run build working-directory: website diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index ef657d5982c..07e4fa348e4 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -14,11 +14,7 @@ name: History Check # the PR head and main to be non-empty. on: - # No paths filter — the job must always run so the required check - # reports a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - branches: [main] + workflow_call: permissions: contents: read diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a9e496fcd4d..95627e7fdeb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,18 +9,12 @@ name: Lint (ruff + ty) # enforcement fails. on: - push: - branches: [main] - paths-ignore: - - "**/*.md" - - "docs/**" - - "website/**" - - # No paths filter — the job must always run so the required check - # reports a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - branches: [main] + workflow_call: + inputs: + event_name: + description: The event name from the calling orchestrator (pull_request or push). + type: string + required: true permissions: contents: read @@ -33,6 +27,7 @@ concurrency: jobs: lint-diff: name: ruff + ty diff + if: inputs.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -41,30 +36,20 @@ jobs: with: fetch-depth: 0 # need full history for merge-base + worktree - # Skip linting on PRs with no Python changes. The job still reports - # green (only the steps below are skipped) so the required check never - # hangs the way an `on.paths` filter would. - - name: Detect affected areas - id: changes - uses: ./.github/actions/detect-changes - - name: Install uv - if: steps.changes.outputs.python == 'true' uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - name: Install ruff + ty - if: steps.changes.outputs.python == 'true' uses: ./.github/actions/retry with: command: uv tool install ruff && uv tool install ty - name: Determine base ref id: base - if: steps.changes.outputs.python == 'true' run: | # For PRs, diff against the merge base with the target branch. # For pushes to main, diff against the previous commit on main. - if [ "${{ github.event_name }}" = "pull_request" ]; then + if [ "${{ inputs.event_name }}" = "pull_request" ]; then BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD) BASE_REF="origin/${{ github.base_ref }}" else @@ -77,7 +62,6 @@ jobs: echo "Base ref: ${BASE_REF}" - name: Run ruff + ty on HEAD - if: steps.changes.outputs.python == 'true' run: | mkdir -p .lint-reports/head ruff check --output-format json --exit-zero \ @@ -88,7 +72,6 @@ jobs: echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes" - name: Run ruff + ty on base (via git worktree) - if: steps.changes.outputs.python == 'true' run: | mkdir -p .lint-reports/base # Use a worktree so we don't clobber the main checkout. If the basex @@ -115,7 +98,6 @@ jobs: echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes" - name: Generate diff summary - if: steps.changes.outputs.python == 'true' run: | python scripts/lint_diff.py \ --base-ruff .lint-reports/base/ruff.json \ @@ -123,12 +105,11 @@ jobs: --base-ty .lint-reports/base/ty.json \ --head-ty .lint-reports/head/ty.json \ --base-ref "${{ steps.base.outputs.ref }}" \ - --head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \ + --head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \ --output .lint-reports/summary.md cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY" - name: Upload reports as artifact - if: steps.changes.outputs.python == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: lint-reports @@ -136,7 +117,7 @@ jobs: retention-days: 14 - name: Post / update PR comment - if: steps.changes.outputs.python == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + if: inputs.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository continue-on-error: true uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 with: @@ -181,25 +162,16 @@ jobs: steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # full history so detect-changes can diff base...head - - - name: Detect affected areas - id: changes - uses: ./.github/actions/detect-changes - name: Install uv - if: steps.changes.outputs.python == 'true' uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 - name: Install ruff - if: steps.changes.outputs.python == 'true' uses: ./.github/actions/retry with: command: uv tool install ruff - name: ruff check . - if: steps.changes.outputs.python == 'true' # No --exit-zero, no || true. Exit code propagates to the job, # which propagates to the required-check gate. run: | @@ -216,19 +188,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # full history so detect-changes can diff base...head - - - name: Detect affected areas - id: changes - uses: ./.github/actions/detect-changes - name: Set up Python - if: steps.changes.outputs.python == 'true' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v5 with: python-version: "3.11" - name: Run footgun checker - if: steps.changes.outputs.python == 'true' run: python scripts/check-windows-footguns.py --all diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index d1b318cc737..48b485c55fd 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -1,8 +1,8 @@ name: OSV-Scanner # Scans lockfiles (uv.lock, package-lock.json) against the OSV vulnerability -# database. Runs on every PR that touches a lockfile and on a weekly schedule -# against main. +# database. Runs on every PR/push (via the ci.yml orchestrator's workflow_call) +# and on a weekly schedule against main. # # This is detection-only — OSV-Scanner does NOT open PRs or modify pins. # It reports known CVEs in currently-pinned dependency versions so we can @@ -10,9 +10,9 @@ name: OSV-Scanner # (full SHA / exact version) is preserved; only the notification signal # is added. # -# Complements the existing supply-chain-audit.yml workflow (which scans -# for malicious code patterns in PR diffs) by covering the orthogonal -# "currently-pinned dep became known-vulnerable" case. +# Complements the supply-chain-audit.yml workflow (which scans for malicious +# code patterns in PR diffs) by covering the orthogonal "currently-pinned +# dep became known-vulnerable" case. # # Uses Google's officially-recommended reusable workflow, pinned by SHA. # Findings land in the repo's Security tab (Code Scanning > OSV-Scanner). @@ -20,19 +20,7 @@ name: OSV-Scanner # vulnerabilities in pinned deps that we may need to patch deliberately. on: - # No paths filter — the job must always run so the required check - # reports a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - branches: [main] - push: - branches: [main] - paths: - - "uv.lock" - - "pyproject.toml" - - "package.json" - - "package-lock.json" - - "website/package-lock.json" + workflow_call: schedule: # Weekly scan against main — catches CVEs published after merge for # deps that haven't changed since. diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index f3405b7660f..201e92d174c 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -1,16 +1,5 @@ name: Supply Chain Audit -on: - # No paths filter — the jobs must always run so required checks - # report a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - types: [opened, synchronize, reopened] - -permissions: - pull-requests: write - contents: read - # Narrow, high-signal scanner. Only fires on critical indicators of supply # chain attacks (e.g. the litellm-style payloads). Low-signal heuristics # (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits, @@ -19,56 +8,40 @@ permissions: # the scanner. Keep this file's checks ruthlessly narrow: if you find # yourself adding WARNING-tier patterns here again, make a separate # advisory-only workflow instead. +# +# Path-gating is handled centrally by the ``ci.yml`` orchestrator's +# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` / +# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those +# inputs instead of re-computing the diff. + +on: + workflow_call: + inputs: + event_name: + description: The event name from the calling orchestrator. + type: string + required: true + scan: + description: Whether supply-chain-relevant files changed. + type: boolean + required: true + deps: + description: Whether pyproject.toml changed. + type: boolean + required: true + mcp_catalog: + description: Whether the MCP catalog / installer changed. + type: boolean + required: true + +permissions: + pull-requests: write + contents: read jobs: - # ── Path filter (shared by both scan and dep-bounds) ─────────────── - changes: - runs-on: ubuntu-latest - outputs: - # True when any file the scanner cares about changed in this PR - scan: ${{ steps.filter.outputs.scan }} - # True when pyproject.toml changed in this PR - deps: ${{ steps.filter.outputs.deps }} - # True when the curated MCP catalog / bundled MCP manifests changed. - mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - name: Check for relevant file changes - id: filter - run: | - BASE="${{ github.event.pull_request.base.sha }}" - HEAD="${{ github.event.pull_request.head.sha }}" - SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \ - '*.py' '**/*.py' '*.pth' '**/*.pth' \ - 'setup.py' 'setup.cfg' \ - 'sitecustomize.py' 'usercustomize.py' '__init__.pth' \ - 'pyproject.toml' || true) - if [ -n "$SCAN_FILES" ]; then - echo "scan=true" >> "$GITHUB_OUTPUT" - else - echo "scan=false" >> "$GITHUB_OUTPUT" - fi - DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true) - if [ -n "$DEPS_FILES" ]; then - echo "deps=true" >> "$GITHUB_OUTPUT" - else - echo "deps=false" >> "$GITHUB_OUTPUT" - fi - MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \ - 'optional-mcps/**' \ - 'hermes_cli/mcp_catalog.py' || true) - if [ -n "$MCP_CATALOG_FILES" ]; then - echo "mcp_catalog=true" >> "$GITHUB_OUTPUT" - else - echo "mcp_catalog=false" >> "$GITHUB_OUTPUT" - fi - scan: name: Scan PR for critical supply chain risks - needs: changes - if: needs.changes.outputs.scan == 'true' + if: inputs.scan runs-on: ubuntu-latest steps: - name: Checkout @@ -111,7 +84,7 @@ jobs: fi # --- base64 decode + exec/eval on the same line (the litellm attack pattern) --- - B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true) + B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true) if [ -n "$B64_EXEC_HITS" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: base64 decode + exec/eval combo @@ -125,7 +98,7 @@ jobs: fi # --- subprocess with encoded/obfuscated command argument --- - PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true) + PROC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true) if [ -n "$PROC_HITS" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: subprocess with encoded/obfuscated command @@ -187,23 +160,9 @@ jobs: echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details." exit 1 - # Gate: reports success when scan was skipped (no relevant files changed). - # This ensures the required check always gets a status. - scan-gate: - name: Scan PR for critical supply chain risks - needs: changes - # always() so the gate still reports SUCCESS even if `changes` fails/is - # skipped — without it, a failed dependency would leave the required - # check unreported (i.e. "pending"), the exact failure mode this fixes. - if: always() && needs.changes.outputs.scan != 'true' - runs-on: ubuntu-latest - steps: - - run: echo "No supply-chain-relevant files changed, skipping scan." - dep-bounds: name: Check PyPI dependency upper bounds - needs: changes - if: needs.changes.outputs.deps == 'true' + if: inputs.deps runs-on: ubuntu-latest steps: - name: Checkout @@ -253,7 +212,7 @@ jobs: $(cat /tmp/unbounded.txt) \`\`\` - **Fix:** Add an upper bound, e.g. \`\"package>=1.2.0,<2\"\` + **Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\` --- *See PR #2810 and CONTRIBUTING.md for the full policy rationale.*" @@ -266,23 +225,9 @@ jobs: echo "::error::PyPI dependencies without upper bounds detected. Add ` in a freshly-spawned subprocess @@ -137,7 +112,6 @@ jobs: NOUS_API_KEY: "" - name: Upload per-slice durations - if: steps.changes.outputs.python == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-durations-slice-${{ matrix.slice }} @@ -183,15 +157,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # full history so detect-changes can diff base...head - - - name: Detect affected areas - id: changes - uses: ./.github/actions/detect-changes - name: Install ripgrep (prebuilt binary) - if: steps.changes.outputs.python == 'true' run: | set -euo pipefail RG_VERSION=15.1.0 @@ -206,7 +173,6 @@ jobs: rg --version - name: Install uv - if: steps.changes.outputs.python == 'true' uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: # Persist uv's download/wheel cache (~/.cache/uv) across runs. @@ -220,11 +186,9 @@ jobs: uv.lock - name: Set up Python 3.11 - if: steps.changes.outputs.python == 'true' run: uv python install 3.11 - name: Install dependencies - if: steps.changes.outputs.python == 'true' # `uv sync --locked` installs the exact pinned set from uv.lock (and # fails if the lock is out of sync with pyproject.toml), giving a # reproducible env. It also creates .venv itself, so no separate @@ -234,19 +198,16 @@ jobs: command: uv sync --locked --python 3.11 --extra all --extra dev - name: Minimize uv cache - if: steps.changes.outputs.python == 'true' # Optimized for CI: prunes pre-built wheels that are cheap to # re-download, keeping the persisted cache small and fast to restore. run: uv cache prune --ci - name: Packaged-wheel i18n smoke test - if: steps.changes.outputs.python == 'true' run: | source .venv/bin/activate python -m pytest -m integration tests/test_wheel_locales_e2e.py -v - name: Run e2e tests - if: steps.changes.outputs.python == 'true' run: | source .venv/bin/activate python -m pytest tests/e2e/ -v --tb=short diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index b52161d3121..1c28bd04cd1 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -2,13 +2,7 @@ name: Typecheck on: - push: - branches: [main] - # No paths filter — the job must always run so the required check - # reports a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - branches: [main] + workflow_call: jobs: typecheck: @@ -20,15 +14,7 @@ jobs: fail-fast: false # report all failures, not just the first one steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # full history so detect-changes can diff base...head - # Skip the install + typecheck on PRs that touch no TypeScript. The job - # still runs and reports green (only the steps below are skipped), so the - # required check never hangs the way an `on.paths` filter would. - - id: changes - uses: ./.github/actions/detect-changes - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - if: steps.changes.outputs.frontend == 'true' with: node-version: 22 cache: npm @@ -36,12 +22,11 @@ jobs: # native builds. Skipping install scripts drops node-pty's node-gyp # header fetch — the transient flake that killed this job pre-`tsc` — and # is faster. retry covers the remaining registry blips. - - if: steps.changes.outputs.frontend == 'true' + - uses: ./.github/actions/retry with: command: npm ci --ignore-scripts - - if: steps.changes.outputs.frontend == 'true' - run: npm run --prefix ${{ matrix.package }} typecheck + - run: npm run --prefix ${{ matrix.package }} typecheck # Production build of the desktop renderer. `typecheck` runs `tsc` only, # which does NOT exercise Vite/Rolldown module resolution — so an @@ -53,20 +38,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 # full history so detect-changes can diff base...head - - id: changes - uses: ./.github/actions/detect-changes - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - if: steps.changes.outputs.frontend == 'true' with: node-version: 22 cache: npm # Keep install scripts here: the production build may need node-pty's # native binary. retry handles the transient install-time fetch flakes. - - if: steps.changes.outputs.frontend == 'true' + - uses: ./.github/actions/retry with: command: npm ci - - if: steps.changes.outputs.frontend == 'true' - run: npm run --prefix apps/desktop build + - run: npm run --prefix apps/desktop build diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 54662b23eda..93c3686daa9 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -44,25 +44,14 @@ name: uv.lock check # the same way. Better to catch it here than after merge. on: - push: - branches: [main] - paths: - - "pyproject.toml" - - "uv.lock" - - ".github/workflows/uv-lockfile-check.yml" - - # No paths filter — the job must always run so the required check - # reports a status (path-gated workflows leave checks "pending" forever - # when no matching files change, which blocks merge). - pull_request: - branches: [main] + workflow_call: permissions: contents: read concurrency: group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true jobs: check: diff --git a/scripts/ci/classify_changes.py b/scripts/ci/classify_changes.py index 2c3c8b5cb3e..c6ce4d5834b 100644 --- a/scripts/ci/classify_changes.py +++ b/scripts/ci/classify_changes.py @@ -6,9 +6,18 @@ booleans (one per lane) to ``$GITHUB_OUTPUT`` and stdout. The ``detect-changes`` composite action consumes them so steps gate on ``if: steps.changes.outputs. == 'true'``. -Lanes: ``python`` (pytest / ruff / ty / footguns), ``frontend`` (TS typecheck -matrix + desktop build), ``site`` (Docusaurus + generated skill docs). Docker -is not a lane — it builds on push-to-main and release only, never per-PR. +Lanes: + +* ``python`` — pytest / ruff / ty / footguns. +* ``docker_meta`` — Dockerfiles etc. +* ``frontend`` — TS typecheck matrix + desktop build. +* ``site`` — Docusaurus + generated skill docs. +* ``scan`` — supply-chain scan (Python files, .pth, setup hooks). +* ``deps`` — pyproject.toml dependency bounds check. +* ``mcp_catalog`` — bundled MCP catalog / installer review. + +Docker is not a lane — it builds on push-to-main and release only, +never per-PR. Contract — *fail open, never closed*. We may run a lane we didn't need, but must never skip one a change could break: @@ -27,10 +36,18 @@ import sys _FRONTEND = ("ui-tui/", "web/", "apps/") # TS typecheck-matrix packages _ROOT_NPM = {"package.json", "package-lock.json"} # shifts every package's tree +_DOCKER_META = ("docker/", ".hadolint.yml", "Dockerfile") # docker setup _SITE = ("website/", "skills/", "optional-skills/") # docs site + skill pages # Prose/frontend trees that can't touch Python. skills/ is excluded on purpose. _PY_SKIP = ("docs/", "website/") + _FRONTEND +# Supply-chain scan: files that can execute code at install/import time. +_SCAN_EXTS = (".py", ".pth") +_SCAN_FILES = {"setup.cfg", "pyproject.toml"} + +# MCP catalog files that require explicit security review. +_MCP_CATALOG_PATHS = ("optional-mcps/",) +_MCP_CATALOG_FILES = {"hermes_cli/mcp_catalog.py"} def _is_docs(p: str) -> bool: if p.startswith(("skills/", "optional-skills/")): @@ -39,18 +56,32 @@ def _is_docs(p: str) -> bool: def _py_irrelevant(p: str) -> bool: - return _is_docs(p) or p in _ROOT_NPM or p.startswith(_PY_SKIP) + return _is_docs(p) or p in _ROOT_NPM or p.startswith(_PY_SKIP) or p.startswith(_DOCKER_META) + + +def _is_scan(p: str) -> bool: + return p.endswith(_SCAN_EXTS) or p in _SCAN_FILES + + +def _is_mcp_catalog(p: str) -> bool: + return p.startswith(_MCP_CATALOG_PATHS) or p in _MCP_CATALOG_FILES def classify(files: list[str]) -> dict[str, bool]: """Map changed paths to ``{lane: should_run}``.""" files = [f.strip() for f in files if f.strip()] if not files or any(f.startswith(".github/") for f in files): - return dict.fromkeys(("python", "frontend", "site"), True) + return dict.fromkeys( + ("python", "docker_meta", "frontend", "site", "scan", "deps", "mcp_catalog"), True + ) return { "python": any(not _py_irrelevant(f) for f in files), + "docker_meta": any(f.startswith(_DOCKER_META) for f in files), "frontend": any(f.startswith(_FRONTEND) or f in _ROOT_NPM for f in files), "site": any(f.startswith(_SITE) for f in files), + "scan": any(_is_scan(f) for f in files), + "deps": any(f == "pyproject.toml" for f in files), + "mcp_catalog": any(_is_mcp_catalog(f) for f in files), } diff --git a/tests/ci/test_classify_changes.py b/tests/ci/test_classify_changes.py index 5a4b474c6af..73c37f8ac60 100644 --- a/tests/ci/test_classify_changes.py +++ b/tests/ci/test_classify_changes.py @@ -1,8 +1,7 @@ -"""Contract tests for scripts/ci/classify_changes.py. +"""Tests for scripts/ci/classify_changes.py. -Each case asserts the *relationship* between a changed-file set and the lanes -that must run — the safety contract of the gating, not a snapshot. Governing -invariant: fail open. We may run a lane we didn't need, never skip one a +Check some common patterns of file modifications and the CI lanes they should run. +We should always fail open. We may run a lane we didn't need, never skip one a change could have broken. """ @@ -15,21 +14,39 @@ import pytest _PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "classify_changes.py" _spec = importlib.util.spec_from_file_location("classify_changes", _PATH) +if _spec is None or _spec.loader is None: + raise ImportError("Failed to load classify_changes.py") _mod = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_mod) classify = _mod.classify -ALL = {"python": True, "frontend": True, "site": True} +ALL = { + "python": True, + "frontend": True, + "docker_meta": True, + "site": True, + "scan": True, + "deps": True, + "mcp_catalog": True, +} -def _lanes(python=False, frontend=False, site=False) -> dict[str, bool]: - return {"python": python, "frontend": frontend, "site": site} +def _lanes(python=False, frontend=False, site=False, scan=False, deps=False, mcp_catalog=False, docker_meta=False) -> dict[str, bool]: + return { + "python": python, + "frontend": frontend, + "docker_meta": docker_meta, + "site": site, + "scan": scan, + "deps": deps, + "mcp_catalog": mcp_catalog, + } CASES = { "docs-only → nothing heavy": (["README.md", "docs/guide.md"], _lanes()), - "python source → python": (["run_agent.py"], _lanes(python=True)), - "dep manifest → python": (["pyproject.toml"], _lanes(python=True)), + "python source → python": (["run_agent.py"], _lanes(python=True, scan=True)), + "dep manifest → python": (["pyproject.toml"], _lanes(python=True, scan=True, deps=True)), "uv.lock → python": (["uv.lock"], _lanes(python=True)), "ts package → frontend": (["apps/desktop/src/app.tsx"], _lanes(frontend=True)), "ui-tui → frontend": (["ui-tui/src/entry.ts"], _lanes(frontend=True)), @@ -39,10 +56,22 @@ CASES = { # SKILL.md reads like docs, but the skill-doc tests read skills/, so a # skill edit must still run Python. "skill md → python + site": (["skills/github/SKILL.md"], _lanes(python=True, site=True)), + "dockerfile → docker meta": (["Dockerfile"], _lanes(docker_meta=True)), # Unknown top-level file keeps Python on rather than risk a silent skip. "unknown toplevel → python": (["Makefile"], _lanes(python=True)), - "mixed docs+python → python": (["README.md", "agent/x.py"], _lanes(python=True)), + "mixed docs+python → python": (["README.md", "agent/x.py"], _lanes(python=True, scan=True)), "mixed docs+frontend → frontend": (["README.md", "apps/x.tsx"], _lanes(frontend=True)), + # Supply-chain lanes + ".pth file → scan": (["evil.pth"], _lanes(python=True, scan=True)), + "setup.py → scan": (["setup.py"], _lanes(python=True, scan=True)), + "mcp catalog manifest → mcp_catalog": ( + ["optional-mcps/foo/manifest.yaml"], + _lanes(python=True, mcp_catalog=True), + ), + "mcp_catalog.py → mcp_catalog": ( + ["hermes_cli/mcp_catalog.py"], + _lanes(python=True, scan=True, mcp_catalog=True), + ), # Fail open: CI-config / empty / blank diffs run everything. ".github change → all": ([".github/workflows/tests.yml"], ALL), "action change → all": ([".github/actions/detect-changes/action.yml"], ALL),