This document explains the changed-file scoped CI test selection implemented in
scripts/select_tests.py and wired into the detect-changes job in
.github/workflows/ci.yml. For runnable, verified examples of targeted selection and
full-suite fallback, jump to
Local testing and worked examples.
CRITICAL: This implementation is safe for use with GitHub branch protection because:
-
Pull Requests Always Run Full Suite
- All PR changes ALWAYS trigger the full test suite (backend + frontend)
- This ensures required checks configured in branch protection never get marked as "skipped"
- A skipped required check would incorrectly block the PR merge
- Example: A docs-only PR still runs all tests to satisfy required checks
-
Push Events Use Selective Skipping
- Only push events (commits to main/develop) use selective skipping
- Push events are informational and don't block merges
- Selective skipping saves CI time on post-merge verification
- Example: A docs-only commit to main skips tests safely (no blocking rules)
| Event | Docs-Only | Backend Only | Frontend Only | Mixed / Config |
|---|---|---|---|---|
pull_request |
✓ Full Suite | ✓ Full Suite | ✓ Full Suite | ✓ Full Suite |
push |
✗ Skip All | ✓ Backend | ✓ Frontend | ✓ Full Suite |
Legend: ✓ = runs tests, ✗ = skips tests
The script classifies changed files into these categories:
-
DOCS (skippable):
.mdfiles anywhere,docs/directory- Safe to skip because docs cannot affect code behavior
- Exception: In PRs, still runs full suite for branch protection
-
FRONTEND:
frontend/directory- Runs frontend tests only (unless mixed with backend)
-
BACKEND:
backend/,testing/backend/,pyproject.toml, and any file underscripts/(regardless of extension) — exceptscripts/check-artifacts.sh- Runs backend tests; includes plugins via plugin dependencies
-
PLUGINS:
plugins/directory- Treated as backend changes (runs backend tests)
-
SHARED_OR_CONFIG (forces full suite):
.github/, root scripts/config files, and anything that matches no category above.github/workflows/changes → full suite (CI behavior changes)setup.sh,docker-compose.yml, rootMakefile→ full suite (system changes)scripts/check-artifacts.sh→ full suite (the onescripts/file kept out of BACKEND)- These affect multiple subsystems and must be fully tested
Changed-file detection lives inside scripts/select_tests.py — the detect-changes
job runs python3 scripts/select_tests.py directly (see .github/workflows/ci.yml),
so there is no separate detect_changes.py. When the workflow does not pass --files,
the script's get_changed_files() helper tries these git commands in order and uses the
first one that returns a non-empty list:
git diff --name-only origin/<base>...HEAD— PRs;<base>=GITHUB_BASE_REF(defaultmain)git diff --name-only <base>...HEADgit diff --name-only HEAD~1git diff --name-only— uncommitted working-tree changes
If every command fails (or all return nothing), get_changed_files() returns an
empty list, which select_tests() treats as "run everything." A detection failure
can therefore never silently skip tests.
In CI the
detect-changesjob checks out withfetch-depth: 0and, for pull requests, fetches the base branch first, so commands (1)–(2) normally succeed.
An empty changed-files list is treated as "unknown," not "nothing changed," so both
push and pull_request fall back to the full suite. See the Empty changeset and
pull request examples under
Local testing and worked examples.
Commands (1)–(3) need at least one ancestor commit. In a git clone --depth=1 checkout
with a single commit they all fail, and command (4) sees no uncommitted changes, so
get_changed_files() returns [] → full suite. This is exactly why CI uses
fetch-depth: 0 instead of a shallow clone.
A path that matches no known prefix (not docs/, frontend/, plugins/, backend/,
…) is classified as SHARED_OR_CONFIG, which forces the full suite (see the Unknown
file example below). This is conservative: new or unclassified file types never bypass
CI.
-
Conservative Fallbacks
- When in doubt, we run the full suite
- Docs-only + shared config → full suite
- Backend + frontend → full suite
- Mixed categories → full suite
- Detection failure → full suite
- Empty detection → full suite
- Unknown file type → full suite
-
Branch Protection Guaranteed
- PRs cannot skip required checks (always full suite)
- Developers cannot accidentally merge untested code
- Required checks will pass/fail, never be skipped
-
Deterministic Classification
- File paths are mapped consistently
- No heuristics or guessing
- Can be verified locally
To modify the test selection policy:
- Update file classification in
scripts/select_tests.py→classify_file() - Update logic in
select_tests()function - Add corresponding unit tests in
testing/backend/unit/test_select_tests.py - Run tests locally:
pytest testing/backend/unit/test_select_tests.py -v - Update this document if behavior changes
Reproduce any selection decision locally with --files (to simulate a changeset) and
--event-name (to simulate the trigger). The comments below are the script's actual
stdout (select_tests.py prints run_backend=... / run_frontend=...).
On push, only the suites touched by the change run:
# Backend-only change → backend suite only
python3 scripts/select_tests.py --files backend/secuscan/routes.py --event-name push
# run_backend=true, run_frontend=false
# Frontend-only change → frontend checks only
python3 scripts/select_tests.py --files frontend/src/App.tsx --event-name push
# run_backend=false, run_frontend=true
# Plugin-only change → backend suite (plugins run under backend)
python3 scripts/select_tests.py --files plugins/nmap/metadata.json --event-name push
# run_backend=true, run_frontend=false
# Docs-only change → nothing runs (selective skip is push-only)
python3 scripts/select_tests.py --files docs/ci-test-selection.md --event-name push
# run_backend=false, run_frontend=falseThe full suite runs whenever the change is mixed, touches shared config, cannot be classified, produces no detected files, or the event is a pull request:
# Mixed backend + frontend → full suite
python3 scripts/select_tests.py --files backend/secuscan/routes.py frontend/src/App.tsx --event-name push
# run_backend=true, run_frontend=true
# Shared CI/config change → full suite
python3 scripts/select_tests.py --files .github/workflows/ci.yml --event-name push
# run_backend=true, run_frontend=true
# Unknown / unclassified file → full suite (treated as SHARED_OR_CONFIG)
python3 scripts/select_tests.py --files Makefile --event-name push
# run_backend=true, run_frontend=true
# Empty changeset (detection returned nothing) → full suite
python3 scripts/select_tests.py --files --event-name push
# run_backend=true, run_frontend=true
# Any pull request → full suite regardless of files (branch-protection safety)
python3 scripts/select_tests.py --files docs/ci-test-selection.md --event-name pull_request
# run_backend=true, run_frontend=trueThese exact decisions are asserted in
testing/backend/unit/test_select_tests.py. Run them withpytest testing/backend/unit/test_select_tests.py -v.
For this to work correctly, configure your branch protection rule on main to require these checks:
formatting-hygiene(always runs, PR-only)backend-lint(skippable based on changes)backend-tests(skippable based on changes)frontend-checks(skippable based on changes)
This allows:
- ✅ Docs-only PR → required checks (formatting) run, optional checks (tests) run full suite
- ✅ Backend-only PR → full suite runs, satisfying all required checks
- ✅ Docs-only push → tests skip safely (push checks are not required)