On this page
Both halves of the CI router's path filters: what the workspace derives for each member, and whether a diff matches them under the action's own semantics.
#rlsbl.router_filters
#rlsbl.router_filters
The generated CI router's path filters: how they are derived, and how they are read.
One module answers both halves of a single question, because the two halves have to agree exactly:
- Which patterns does the router emit for a member?
:class:RouterFilters derives them from the workspace itself -- ownership territories, dependency territories, a small set of built-ins, and (for the root member) negated excludes. Nothing is hand-declared in workspace.toml: the workspace's own structure states what a member's CI must react to.
- Would a given diff match them? :func:
matches_filteranswers with the
same semantics dorny/paths-filter applies at run time, so the release flow's pre-push simulation and the action agree about whether a candidate push can trigger a member's job.
Keeping the emitter and the reader apart is what let them drift: a simulation that ORs every pattern together is right until the first negated pattern, at which point it says "matched" for exactly the files the action excludes.
The predicate quantifier is part of the contract. dorny/paths-filter defaults to some: a file matches a filter when any pattern matches it, and a negated pattern under that rule matches everything outside itself, so ['**', '!pkg/**'] matches every file including the ones under pkg/. The router therefore declares predicate-quantifier: some-with-excludes (:data:PREDICATE_QUANTIFIER), under which a file matches when at least one non-negated pattern matches it and no negated pattern does. Exclusion is final and order-independent -- an excluded file cannot be included back by a later pattern. For a member whose pattern list carries no negation the two quantifiers agree exactly, so only the root member's filter changes meaning.
#is_generated_router
def is_generated_router(filepath) -> boolDoes filepath carry the generated-router header?
#_target_manifest_names
def _target_manifest_names() -> tuple[str, ...]Every filename a registered target detects itself by.
Derived from the target registry rather than restated, so a new target's manifest joins the root-trigger set the moment the target is registered. Imported lazily: :mod:rlsbl.targets pulls in most of the package, and this module is imported by parts of it.
#root_trigger_files
def root_trigger_files(root) -> list[str]Workspace-root manifests and lockfiles, restricted to the ones present.
A manifest or lockfile at the workspace root describes the build of the repository as a whole -- a dependency bump there can change what every member compiles against -- so a change to one triggers every member's CI, including members whose own directory the diff never touched.
Only files that actually exist are emitted. A filter listing paths that can never appear would be dead weight in every member's block, and the freshness check re-derives from the same tree, so adding a root lockfile makes the committed router stale (correctly: it now needs the entry).
#territory_pattern
def territory_pattern(member) -> strThe glob covering everything a member owns.
** for the root member, whose territory is the residual -- every path no other member claims. The residual cannot be written as a positive glob, so it is expressed as ** narrowed by negated excludes, which only :meth:RouterFilters.patterns_for can build (it needs the other members). A caller that just wants "the root member's area" gets the over-broad ** and must live with it.
#_finalize_artifact
def _finalize_artifact(project, releasables)Repo-relative path of the releasable artifact a release commit writes.
In explicit releasable mode a release finalizes the releasable's CHANGELOG.md under .rlsbl-monorepo/releasables/<name>/. That file is part of the project's own change surface: a release commit IS a change to the released project, so it must satisfy the router's paths filter.
This entry is a deliberate run-everything hook, not an accident.
Mechanism: the finalize artifact is one path shared by every member of the releasable, and it is appended to each member's filter. A commit that touches it therefore matches all of their filters at once and triggers all of their CI jobs. The release commit always touches it -- the version-bump commit regenerates and commits the releasable CHANGELOG.md (it gains the new version's heading) before the candidate push.
Why it exists: a release push can touch nothing under a member's own path. That is inevitable on a FIRST release, where the version write is a no-op, and it is possible on any release whose per-member writes all land outside the member's directory. The member's CI job would then conclude skipped on the very commit its tag points at, and the publish gate deliberately refuses to treat skipped as passing (:mod:rlsbl.publish_gate) -- a skipped check proves nothing about the commit. The result is a deadlock with no re-runnable recovery: re-running CI on that commit skips the job again, for the same reason it skipped the first time. Rooting every member's filter on the one file a release always writes makes the verified commit readable for all of them.
The cost, accepted knowingly: any release of a releasable runs the full CI job set of every one of its members, including members whose own code did not change. That is the intended trade -- CI minutes in exchange for never tagging a commit the gate cannot read a verdict for.
Deliberately the finalize artifact only, NOT the whole releasable directory: rlsbl changelog add writes the JSONL between releases and must not spend CI minutes on every entry.
Returns None outside explicit releasable mode or for a non-releasable project.
#RouterFilters
Derives every member's router path filters from the workspace.
Construction builds the workspace dependency graph. Its failures are not caught here and must not be caught by callers: a workspace whose depends_on names a project that does not exist has no derivable filter set, and a router generated as if the edge were absent would silently under-trigger the dependent's CI. The command reports the error instead.
A manifest the graph could not READ is the same failure wearing different clothes, and so is a Gradle file that parsed but declares a dependency in a form no scanner recognizes. The graph is deliberately tolerant of both (see :class:~rlsbl.workspace_graph.WorkspaceGraph) -- so this constructor asks the graph whether any scan failed and refuses when one did. The narrowing is otherwise invisible from here and from the freshness check, which re-derives from the same unreadable manifest or declaration and therefore agrees the narrowed router is fresh.
The refusal's own remedy is its way out, and it really is one: a member whose workspace.toml entry declares depends_on has stated its workspace edges by hand, so an unrecognized Gradle line in it can no longer narrow anything and the graph does not record it (:meth:~rlsbl.workspace_graph.ManifestScanError.acknowledged_by) -- the warning still reaches stderr. Nothing else is acknowledged this way: an unreadable manifest withheld a whole dependency section, which no declaration answers for.
#_relative
def _relative(self, path)path relative to the workspace root, when it is under it.
#_dependency_members
def _dependency_members(self, project)Every workspace member project depends on, transitively, any scope.
All four scopes participate (runtime, dev, peer, explicit): a change to a dev-scoped dependency breaks the dependent's tests, which is precisely what its CI job runs. Nothing here filters by scope, and nothing should -- the boundary checks that care about scope are a different question.
#patterns_for
def patterns_for(self, project) -> list[str]The dorny/paths-filter patterns the router emits for one member.
A push whose diff matches none of these leaves that member's CI job skipped on the pushed commit -- and the publish gate refuses to treat a skipped check as passing. The release engine therefore has to guarantee that the commit it tags rode in on a push whose diff matches every participating member's patterns, which is why this is a shared, published derivation rather than a local detail of the generator.
The list is deterministic: own territory, then dependency territories, then root triggers, then tool machinery, then excludes -- each group sorted. The freshness check compares text, so a stable order is part of the contract.
#filters_block
def filters_block(self, projects=None) -> strThe filters: value the router's paths-filter step carries.
projects selects and orders the members that appear; it defaults to every member of the workspace. The generator passes the members that actually contribute CI jobs.
#_glob_to_regex
def _glob_to_regex(pattern: str) -> re.PatternTranslate one non-globstar-suffixed glob the way picomatch would.
* stops at a path separator, ** crosses them, ? matches one non-separator character. Everything else is literal. The router emits exact paths and directory globstars, so this branch mostly handles exact paths; it is written generally so a corpus case with an interior globstar gets a real answer rather than an accident of :mod:fnmatch, whose * happily crosses /.
#matches_pattern
def matches_pattern(path: str, pattern: str) -> boolDoes one repo-relative path match one (non-negated) pattern?
! is not handled here: negation is a property of the pattern list (see :func:matches_filter), and a single pattern read in isolation cannot say whether a file is in or out.
#matches_filter
def matches_filter(path: str, patterns) -> boolDoes path match a whole filter, negation and all?
The some-with-excludes rule the router declares (:data:PREDICATE_QUANTIFIER): at least one non-negated pattern matches and no negated pattern does. Exclusion is final -- a file an exclude removes cannot be brought back by another include -- so the result does not depend on the order the patterns appear in.
A filter of negated patterns only never matches anything: there is no pattern that could include a file in the first place.
#any_path_matches
def any_path_matches(paths, patterns) -> boolWould a diff touching paths trigger a job filtered by patterns?
The whole-filter question asked per path. Never any(matches_pattern(p, pat) for p in paths for pat in patterns): that cross product treats an exclude as an independent way to match and reports a trigger for exactly the diffs the action drops.