Selective Test Execution
By default, every CI run executes every test suite for every stack in your project. This is safe but slow. Selective test execution runs only the test suites for stacks affected by a change, cutting CI time without sacrificing coverage. This tutorial walks through four real-world scenarios, shows the before/after CI job differences, and explains the safety rails that prevent under-testing.
Prerequisites
You need a project-config.yaml with at least one entry
in the stacks section. Each stack must have a
name and a paths list (or a scalar
path). Read
Configuring CI Pipelines
and
Test Strategy Configuration
first if you have not set those up yet.
Overview
Selective test execution is a GAIA-owned CI pipeline feature. It
replaces GitHub's native paths: triggers (which cannot
express transitive dependencies between stacks) with a six-stage
pipeline that maps changed files to affected stacks, walks the
dependency graph, applies safety escalations, and emits a GitHub
Actions matrix so only the relevant jobs run.
When test_policy is absent from your config, every
stack's tests run on every trigger -- identical to the behavior
before selective execution existed. Adding the section is opt-in
and backward-compatible.
How it works
Six scripts run in sequence during each CI invocation. Each stage
can escalate the affected set to ["*"] (all stacks),
but none can subtract from it. The selective-test-driver.sh
script chains stages 1 through 5 into a single invocation that emits
the final GitHub Actions matrix JSON on stdout. The
selective-tests.yml workflow calls the driver in a
plan job and fans out per-stack test jobs via the matrix.
| Stage | Script | What it does |
|---|---|---|
| 1. Detect | detect-affected.sh |
Maps each changed file to its owning stack via longest-prefix
match against stacks[].paths globs. A
promotion-push event short-circuits to ["*"]. |
| 2. Walk | cross-refs-walk.sh |
Reads the cross_refs dependency index for each
stack. If stack A is affected and stack C declares A in its
cross_refs, C is pulled in transitively. A
dependency cycle escalates to ["*"]. |
| 3. Reconcile | reconcile-stale-graph.sh |
Detects undeclared import edges (code that imports a stack
not listed in cross_refs). An undeclared edge
escalates to ["*"] so a stale graph never causes
under-testing. |
| 4. Policy | apply-test-policy.sh |
Merges the always_run set additively and applies
per-trigger scope narrowing from test_policy.triggers.
The --force-full-run flag escalates to
["*"]. |
| 5. Generate | generate-pipeline.sh |
Converts the affected-set JSON into a GitHub Actions matrix:
{"include":[{"stack":"x"}]}. A wildcard
["*"] expands to every stack. An empty set
([], e.g. a docs-only change) produces no jobs
and exits cleanly. |
| 6. Retry | run-with-retry.sh |
Retries tests tagged as flaky up to
retry_limit times. An exhausted retry escalates
the failure to a real CI-blocking failure. |
Scenario 1: Single-stack project
Setup
A Node.js API with one stack. All source lives under src/.
# .gaia/config/project-config.yaml
stacks:
- name: api
path: src
language: typescript
test_command: npm test
What happens
Every code change touches the single stack, so detect-affected.sh
always returns ["api"]. There is no dependency graph to walk
and no cross-stack narrowing to apply. The CI matrix contains one job.
Before / after comparison
| Change | Without selective execution | With selective execution |
|---|---|---|
Edit src/handler.ts |
1 job: api |
1 job: api |
Edit README.md |
1 job: api |
0 jobs (docs-only, no stack matched) |
Takeaway
For a single-stack project the main benefit is skipping CI entirely on documentation-only changes. If your CI takes 8 minutes, that is 8 minutes saved on every README or config edit.
Scenario 2: Full monorepo
Setup
A monorepo with three independent stacks: a React frontend, a Python API, and a Go worker service. No stack imports code from another.
# .gaia/config/project-config.yaml
stacks:
- name: frontend
path: packages/web
language: typescript
test_command: npm test
- name: backend
path: packages/api
language: python
test_command: pytest
- name: worker
path: packages/worker
language: go
test_command: go test ./...
What happens
detect-affected.sh maps each changed file to its owning
stack. Because no stack declares cross_refs, the
dependency walk adds nothing.
Before / after comparison
| Change | Without selective execution | With selective execution |
|---|---|---|
Edit packages/web/src/App.tsx |
3 jobs: frontend, backend, worker |
1 job: frontend |
Edit packages/api/routes.py |
3 jobs: frontend, backend, worker |
1 job: backend |
Edit both packages/web/ and packages/api/ |
3 jobs: frontend, backend, worker |
2 jobs: frontend, backend |
Edit docs/architecture.md |
3 jobs: frontend, backend, worker |
0 jobs (docs-only) |
Expected CI matrix output
For a change to packages/web/src/App.tsx only:
{"include":[{"stack":"frontend"}]}
Takeaway
A three-stack monorepo with no cross-dependencies sees a 66% reduction in CI jobs on single-stack changes. The savings scale linearly with the number of independent stacks.
Scenario 4: Per-trigger test policy configuration
Setup
The same four-stack project, now with a test_policy
section that scopes test execution differently depending on the CI
trigger.
# .gaia/config/project-config.yaml
test_policy:
always_run:
- shared-lib
flaky:
- frontend-e2e-checkout
retry_limit: 3
triggers:
pr:
exclude_tags:
- slow
push:
include_stacks:
- backend
- worker
schedule:
include_tags:
- slow
- e2e
What each trigger does
| Trigger | Scope rule | Effect |
|---|---|---|
pr |
exclude_tags: [slow] |
Run the affected stacks' tests but skip tests tagged
slow. The always_run set
(shared-lib) is added on top. |
push |
include_stacks: [backend, worker] |
After affected-set detection, narrow the matrix to only
backend and worker. Any other
affected stack is excluded from this trigger. The
always_run set is still added. |
schedule |
include_tags: [slow, e2e] |
Run only tests tagged slow or e2e
across all stacks. This is the nightly full-coverage tier. |
Before / after comparison (on a PR that touches the frontend)
| Trigger | Without test_policy | With test_policy |
|---|---|---|
| PR opened | 4 jobs, all tests | 2 jobs (frontend + shared-lib
from always_run), no slow tests |
| Push to staging | 4 jobs, all tests | 1 job (shared-lib from
always_run); frontend is not in
include_stacks for the push trigger |
| Nightly schedule | 4 jobs, all tests | 4 jobs, but only slow and e2e
tagged tests |
Important: include and exclude are mutually exclusive
For each trigger, you can specify include_stacks
or exclude_stacks, but not both.
The same applies to include_tags and
exclude_tags. The config schema rejects combinations
that specify both, with a validation error pointing to the exact
location in your YAML.
Safety rails
Selective execution trades completeness for speed. The pipeline includes four safety mechanisms that prevent this trade-off from causing under-testing. Each one is explained below with a concrete example.
1. The always_run set
The test_policy.always_run array lists stacks (or
suite identifiers) whose tests run on every CI invocation,
regardless of which files changed. This is additive -- it can
never subtract from the affected set.
test_policy:
always_run:
- shared-lib
- critical-auth
Example: A developer edits only
packages/web/src/Button.tsx. The affected set is
["frontend"]. After applying always_run,
the set becomes ["frontend", "shared-lib", "critical-auth"].
The shared library and authentication tests run even though their
code did not change.
2. Stale-graph escalation
reconcile-stale-graph.sh reuses the brownfield
cross-stack import-edge detector to find undeclared dependencies.
If your code imports a module from another stack but that stack is
not listed in cross_refs, the dependency graph is
stale. The script escalates the entire run to ["*"]
(all stacks) and logs a warning naming the undeclared edge.
# Example warning on stderr:
# reconcile-stale-graph: undeclared import edge: worker -> shared-lib
# Escalating to full suite ["*"]
Why this matters: Without this check, a change to
shared-lib would not trigger the worker's tests even
though the worker depends on it. The escalation ensures you are
never under-tested because of a stale config. Fix the config by
adding the missing cross_refs entry, and subsequent
runs will narrow correctly again.
3. Promotion-push wildcard
When a change is promoted to the final tier of your
promotion chain, detect-affected.sh receives a
--event promotion-push flag and immediately returns
["*"] without inspecting any files. Every stack's tests
run on that promotion.
The full-suite tier is derived from
ci_cd.promotion_chain — specifically the
last tier's branch — never hard-coded. The reference
selective-tests.yml reads the chain, determines the
PR/push base branch, and passes --event promotion-push
only when the base is that final tier. So a project that promotes
feature → staging → main
runs the narrowed affected-set on
feature → staging (the fast dev loop) and
the full suite only on
staging → main (the promotion gate). A
project whose final tier is named something other than
main still escalates correctly. If the chain cannot be
read, the workflow falls back to treating main as the
final tier.
Why this matters: Promotion merges to the final
tier are the last gate before production. Running the full suite here
catches integration issues between stacks that were tested
independently during the PR phase. The wildcard is applied before any
policy narrowing, so per-trigger include_stacks filters
cannot reduce a promotion push.
4. The --force-full-run escape hatch
Pass --force-full-run to apply-test-policy.sh
to override all narrowing and run every stack's tests. This
escalates the affected set to ["*"].
# In your CI workflow step:
apply-test-policy.sh --force-full-run --trigger pr \
--config .gaia/config/project-config.yaml
When to use it: After a major dependency upgrade, a framework version bump, or any change where you are uncertain which stacks may be affected. The flag is a manual override, not an automatic behavior -- use it when you want explicit full coverage.
Configuring test_policy
The test_policy section lives in
.gaia/config/project-config.yaml. When the section is
absent, all tests run for all stacks on every trigger. Adding it
opts you into selective execution.
Minimal configuration
The simplest useful configuration sets an always_run
list and nothing else. All other fields have safe defaults.
# .gaia/config/project-config.yaml
test_policy:
always_run:
- core
Full configuration
A complete test_policy section with all fields:
# .gaia/config/project-config.yaml
test_policy:
always_run:
- shared-lib
- auth-service
flaky:
- checkout-e2e
- payment-integration
retry_limit: 2
triggers:
pr:
exclude_tags:
- slow
- nightly
push:
exclude_stacks:
- docs
schedule:
include_tags:
- slow
- e2e
- security
Field reference
| Field | Type | Default | Description |
|---|---|---|---|
always_run |
string[] | [] |
Stacks or suite identifiers that always run, regardless of affected-set narrowing. |
flaky |
string[] | [] |
Test identifiers flagged for retry-aware execution. |
retry_limit |
integer | 2 |
Maximum retries for flaky tests. A test that still fails after all retries is treated as a real failure. |
triggers.pr |
scope rule | (none) | Scope filters for pull-request triggers. |
triggers.push |
scope rule | (none) | Scope filters for push triggers. |
triggers.schedule |
scope rule | (none) | Scope filters for scheduled (nightly/weekly) triggers. |
Scope rule fields (per trigger)
| Field | Type | Description |
|---|---|---|
include_stacks |
string[] | Run tests only for these stacks. |
exclude_stacks |
string[] | Run tests for all stacks except these. |
include_tags |
string[] | Run only tests matching these tags. |
exclude_tags |
string[] | Exclude tests matching these tags. |
Mutual exclusivity
Within a single trigger, you cannot combine
include_stacks with exclude_stacks, or
include_tags with exclude_tags. The
config schema rejects these combinations with a validation error
that includes the JSONPath location of the conflict.
Validating your configuration
After editing test_policy, validate the config:
/gaia-config-validate
Invalid stack references in always_run,
include_stacks, or exclude_stacks produce
a validation error with the exact JSONPath location. Fix the
reference and re-validate.
Flaky test retries
run-with-retry.sh handles tests flagged in the
test_policy.flaky list. When a flaky-tagged test
fails, it is retried up to retry_limit times. If it
passes on any retry, the failure is suppressed. If it exhausts all
retries, the failure escalates to a real CI-blocking failure --
flaky tests never silently pass.
test_policy:
flaky:
- checkout-e2e
retry_limit: 3
In this example, the checkout-e2e test gets up to 3
retries on failure. If it fails 4 times in a row (1 initial + 3
retries), the CI run fails.
What to read next
- Test Strategy Configuration -- how to configure test tiers, the test pyramid, and the Test Execution Bridge.
- Configuring CI Pipelines -- promotion chains, presets, triggers, and the layered CI model.
- CI Scenarios by Team Size -- concrete configurations for solo through enterprise setups.
- Project Shapes Overview -- how project structure affects CI and test configuration.