ci: run only the lanes a PR affects (python/frontend/site)

Heavy PR checks run on every PR because the workflows deliberately avoid
`on.paths` filters — a path-gated workflow leaves its required check pending
forever when no matching file changes, blocking merge. So a docs-only PR
still spins up the TypeScript matrix, the full Python suite, and ruff/ty.

Keep every workflow triggering on every PR (checks always report) but gate
the expensive *steps* on what the PR touches. Skipping a step (not the job)
leaves the job green, so required checks never hang — the same idiom already
proven in contributor-check.yml.

A classifier (scripts/ci/classify_changes.py) maps the PR diff to three
lanes — python, frontend, site — surfaced as step outputs by a composite
action (.github/actions/detect-changes). Fail-open: an empty diff or any
.github/ change runs everything; python is a denylist (skipped only when
every file is provably prose or a frontend-only package); skills/**/SKILL.md
counts as python-relevant since the skill-doc tests read that tree. Non-PR
events always run the full pipeline.
This commit is contained in:
Brooklyn Nicholson 2026-06-19 16:46:11 -05:00 committed by ethernet
parent 351afd353d
commit 45540cfb5e
7 changed files with 272 additions and 5 deletions

View file

@ -0,0 +1,48 @@
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`.
outputs:
python:
description: Run Python tests / ruff / ty / windows-footguns.
value: ${{ steps.classify.outputs.python }}
frontend:
description: Run the TypeScript typecheck matrix + desktop build.
value: ${{ steps.classify.outputs.frontend }}
site:
description: Build the Docusaurus docs site.
value: ${{ steps.classify.outputs.site }}
runs:
using: composite
steps:
- name: Classify changed files
id: classify
shell: bash
env:
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.
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)"
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

View file

@ -17,34 +17,51 @@ 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'
run: 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'
run: 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

View file

@ -41,16 +41,26 @@ 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'
run: |
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.
@ -67,6 +77,7 @@ 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 \
@ -77,6 +88,7 @@ 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
@ -103,6 +115,7 @@ 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 \
@ -115,6 +128,7 @@ jobs:
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
@ -122,7 +136,7 @@ jobs:
retention-days: 14
- name: Post / update PR comment
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
if: steps.changes.outputs.python == 'true' && github.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:
@ -167,14 +181,23 @@ 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'
run: 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: |
@ -191,11 +214,19 @@ 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

View file

@ -31,8 +31,18 @@ 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
# On PRs that touch no Python, every step below is skipped and the job
# reports green. The check still runs (no `on.paths` filter), so the
# required status never hangs.
- name: Detect affected areas
id: changes
uses: ./.github/actions/detect-changes
- name: Restore duration cache
if: steps.changes.outputs.python == 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: test_durations.json
@ -44,6 +54,7 @@ jobs:
key: test-durations
- name: Install ripgrep (prebuilt binary)
if: steps.changes.outputs.python == 'true'
run: |
set -euo pipefail
RG_VERSION=15.1.0
@ -58,6 +69,7 @@ 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.
@ -71,9 +83,11 @@ 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
@ -81,11 +95,13 @@ jobs:
run: 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: Run tests (slice ${{ matrix.slice }}/6)
if: steps.changes.outputs.python == 'true'
# Per-file isolation via scripts/run_tests_parallel.py: discovers
# every test_*.py file under tests/ (excluding integration/ + e2e/),
# then runs `python -m pytest <file>` in a freshly-spawned subprocess
@ -119,6 +135,7 @@ 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 }}
@ -164,8 +181,15 @@ 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
@ -180,6 +204,7 @@ 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.
@ -193,9 +218,11 @@ 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
@ -203,16 +230,19 @@ jobs:
run: 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

View file

@ -20,12 +20,22 @@ 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
- run: npm ci
- run: npm run --prefix ${{ matrix.package }} typecheck
- if: steps.changes.outputs.frontend == 'true'
run: npm ci
- if: steps.changes.outputs.frontend == 'true'
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
@ -37,9 +47,16 @@ 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
- run: npm ci
- run: npm run --prefix apps/desktop build
- if: steps.changes.outputs.frontend == 'true'
run: npm ci
- if: steps.changes.outputs.frontend == 'true'
run: npm run --prefix apps/desktop build