- Published on
Monorepo CI Selection Without Skipping Dependent Packages
- Authors

- Name
- Mehdi Akiki
Article · Derived state
To select monorepo tests safely, include the changed packages and the packages that depend on them, then require a successful result for every selected check. An unknown path should trigger a deliberate full run. A broken selector should fail the workflow instead of quietly selecting nothing.
Suppose an agent changes a date formatter. Running every browser suite feels expensive, so someone suggests testing only the formatter directory. That misses the report package that imports it and the application that imports the report package.
Here is a small Node.js example of selection and result checking. It is a constructed repository graph, not a claim that this site has those applications or uses this selector in production. The complete selector and tests are kept together so the example can be verified in CI.
Declare the dependencies you intend to trust
The fixture graph contains five checks:
formatting <- reporting <- app-web
formatting <- app-admin
docs (independent in this fixture)
Here an arrow points from an imported package toward its consumer. A change to formatting selects formatting, reporting, app-web, and app-admin. A change inside app-web selects only app-web. Documentation has its own check and is selected when its files change.
Each package record has a name, an owned directory, and dependency names. The selector rejects duplicate names, overlapping ownership directories, missing dependencies, and malformed paths. Otherwise an ambiguous directory could make the same edit belong to whichever record happened to appear first.
This graph is maintained explicitly. It does not discover dynamic imports, schema consumers, generated code, or services outside the repository. In an established monorepo, use its existing task graph if that graph already represents these relationships. A tidy JSON file that omits a real consumer is still wrong.
Review graph changes with the code that creates the relationship. If reporting starts importing formatting, its dependency record must change in that same review. Selection cannot protect a dependency nobody told it about.
Walk outward until no new consumer appears
The selector first finds owners of changed paths. It then repeatedly adds consumers of anything already selected:
let expanded;
do {
expanded = false;
for (const node of graph) {
if (
!selected.has(node.name) &&
node.dependencies.some((dependency) => selected.has(dependency))
) {
selected.add(node.name);
expanded = true;
}
}
} while (expanded);
The second pass matters. Finding reporting is not enough; app-web is affected through reporting even though it does not import formatting directly. The loop stops because it adds each finite graph node at most once. It selects checks; it does not decide the build order of dependent packages.
The CLI accepts the graph and a JSON array of changed repository paths:
node experiments/editorial-ci/select-affected.mjs \
experiments/editorial-ci/package-graph.json \
experiments/editorial-ci/changed-files.json
Run example verification through CI. For the supplied formatting path, the expected selection is app-admin, app-web, formatting, and reporting. That list is an expected result, not a captured run result until a remote verification link is recorded.
The workflow collecting the diff must handle both old and new names for renames and compare against the intended base. If that base is unavailable, request the full declared suite explicitly. A failed diff command must not be turned into an empty array.
Make uncertainty visible
Root package settings, lockfiles, and workflow edits select every declared check. Unknown paths do too, with a reason naming the path. That conservative rule prevents a new root script from being silently ignored merely because package ownership does not cover it yet.
A missing or invalid graph is different. The selector stops with an error because it cannot even establish what the full suite contains. The final gate must treat that selector failure as a failure.
An explicit empty diff can legitimately select no package checks. That result must come from a successful comparison. It is not a fallback for parsing errors, unavailable history, or a cancelled selector job.
The supplied tests distinguish these cases. They cover direct and transitive consumers, an application-only change, lockfiles, unknown paths, missing comparison data, and an invalid graph. These are the boundaries that make this example more than a list of preferred test names.
Require the selected results, including failures
After the runner executes the selected checks, the gate receives their actual conclusions:
requiredResult({
selectorConclusion: "success",
selection: { kind: "affected", checks: ["app-web"] },
results: [{ name: "app-web", conclusion: "success" }],
});
The fixture rejects a failed selector and selected results that are failed, skipped, cancelled, duplicated, or missing. Only an explicit success for each selected name is accepted. The report is an adapter input from trusted workflow plumbing, not a file the pull request gets to fill with its own success claims.
GitHub's required-check guidance explains why this matters: workflow path filters can leave required checks pending, while skipped jobs behave differently. A final dependent job needs an appropriate always-run condition and must inspect the selector as well as downstream results. The fixture is the decision logic, not a complete replacement workflow for every repository.
Keep other required checks outside this selection if they protect repository-wide rules. Selecting application checks does not authorize removing security, formatting, or release checks. Concurrency policy is another decision; reducing the selection will not repair runs that continuously cancel each other.
For the formatter change, the useful outcome is a clear list of consumers and a gate that notices when one never produced a result. Faster CI should still leave that evidence behind.