Files
hermes-agent/.github/actions/detect-changes/action.yml
emozilla 5c08ad68f7 ci: run the slow lanes on a PR only when it touches their paths
The E2E suites, the Windows E2E jobs, the Docker image build and the Nix
flake check took about 120 of the ~140 larger-runner minutes each PR push
spent, and fired on nearly every PR. The 32-core pool backed up behind
them.

On a pull request each slow lane now runs only when the diff touches the
suite, its harness, or the code it guards (classify_changes.py
_E2E_LANES, _DOCKER_PATHS, _NIX_LANE_PATHS), or when the PR carries the
run-e2e label. Every push to main still runs all of them, and a release
run still forces every lane on.

Adding run-e2e reruns the head commit's CI, Docker and Nix runs; detect
reads the live labels because a rerun replays the original event.

Replayed over the last 200 merged PRs, the modelled 32-core cost of one
push drops from about 120 to about 10 runner-minutes.
2026-09-30 14:37:52 -04:00

139 lines
6.1 KiB
YAML

name: Detect affected areas
description: >-
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.
inputs:
github-token:
description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow.
required: false
default: ${{ github.token }}
outputs:
python:
description: Run Python tests / ruff / ty / windows-footguns.
value: ${{ steps.classify.outputs.python }}
python_prod:
description: Python changes outside tests/.
value: ${{ steps.classify.outputs.python_prod }}
frontend:
description: Run the TypeScript testing matrix + desktop build.
value: ${{ steps.classify.outputs.frontend }}
docker_meta:
description: Docker setup and meta files have changed.
value: ${{ steps.classify.outputs.docker_meta }}
docker:
description: Build and test the Docker image (docker meta, dependency manifests, PM, tests/docker).
value: ${{ steps.classify.outputs.docker }}
nix:
description: Run `nix flake check` (flake files and dependency manifests).
value: ${{ steps.classify.outputs.nix }}
e2e:
description: Run the Python end-to-end suites (tests/e2e, except upgrade).
value: ${{ steps.classify.outputs.e2e }}
e2e_upgrade:
description: Run the N-1 to HEAD `hermes update` suites (tests/e2e/core/upgrade).
value: ${{ steps.classify.outputs.e2e_upgrade }}
e2e_desktop_core:
description: Run the Desktop core Electron suite (apps/desktop/e2e/core).
value: ${{ steps.classify.outputs.e2e_desktop_core }}
e2e_desktop_update:
description: Run the Desktop install/update Electron suite (apps/desktop/e2e/update).
value: ${{ steps.classify.outputs.e2e_desktop_update }}
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 }}
uv_lock:
description: Run the PM project-lock check (pyproject.toml / uv.lock changes only).
value: ${{ steps.classify.outputs.uv_lock }}
npm_lock:
description: Post/update the semantic package-lock.json diff PR comment.
value: ${{ steps.classify.outputs.npm_lock }}
bootstrap:
description: Run the bootstrap installer lane (install.sh sandbox + stamp verification).
value: ${{ steps.classify.outputs.bootstrap }}
desktop_updater:
description: Run the Windows desktop-update hand-off (windows.ps1) integration tests.
value: ${{ steps.classify.outputs.desktop_updater }}
rust:
description: Run `cargo test` for the Tauri bootstrap installer.
value: ${{ steps.classify.outputs.rust }}
mcp_catalog:
description: Require MCP catalog security review label.
value: ${{ steps.classify.outputs.mcp_catalog }}
ci_review:
description: Require CI-sensitive file review label.
value: ${{ steps.classify.outputs.ci_review }}
ci_review_files:
description: JSON list of CI-sensitive files changed by the pull request.
value: ${{ steps.classify.outputs.ci_review_files }}
runs:
using: composite
steps:
- name: Classify changed files
id: classify
shell: bash
env:
# Fall back to the built-in read-only token when the caller passes an
# empty value. Fork PRs get no repo secrets, so AUTOFIX_BOT_PAT is ""
# there, and an input `default:` only applies when the input is omitted,
# not when it's passed empty. Without this fallback the compare API
# fails on forks and the classifier fails open (every lane forced on).
GH_TOKEN: ${{ inputs.github-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.
if [ "$EVENT_NAME" = "pull_request" ]; then
# 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.
#
# Retried: a rate-limit blip or eventual-consistency 404 on a
# freshly-pushed HEAD would otherwise silently fall open (all lanes
# run — safe, but wasteful and it masks the API failure).
#
# `.files[]?` (null-safe): with --paginate, a PR more than 100
# commits ahead of its merge-base paginates the compare, and pages
# after the first carry `files: null` — bare `.files[]` makes jq
# die with "cannot iterate over: null", which fails every retry
# and forces the fail-open path (seen on stacked PRs). The full
# file list (up to the API's 300-file cap) is on page one.
CHANGED=""
for i in 1 2 3; do
if CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[]?.filename')"; then
break
fi
if [ "$i" = 3 ]; then
echo "::warning::compare API failed after 3 attempts — failing open (all lanes run)"
CHANGED=""
break
fi
echo "::warning::compare API failed (attempt $i); retrying in 10s"
sleep 10
done
fi
echo "Changed files:"
printf '%s\n' "${CHANGED:-(none)}"
printf '%s\n' "${CHANGED:-}" | python3 scripts/ci/classify_changes.py