Summary
On moon run / moon exec, --upstream / --downstream (together with the marking --include-relations enables) control two distinct things at once, with no way to separate them:
- Affected-mark propagation — how far the "affected" signal spreads across graph edges to decide whether a requested target is affected.
- Run-set expansion — which non-requested related tasks also get inserted into the action graph and executed.
There is no way to say "propagate affected along dependency edges so my requested target is correctly marked, but do not also pull that target's dependents into the run." The result: a deliberately-scoped moon exec ':<task>' in CI runs tasks it was never asked to.
Where they're coupled
In crates/app/src/commands/exec.rs (build_action_graph), the same upstream/downstream values feed both the affected tracker and the run requirements:
let upstream = self.get_upstream();
let downstream = self.get_downstream();
if self.affected {
action_graph_builder
.track_affected(upstream, downstream, ...).await?; // (1) marking scope
}
let partition = action_graph_builder
.run_tasks_with_plan(&self.plan, RunRequirements {
dependencies: upstream, // (2) run scope
dependents: downstream, // (2) run scope
include_relations: self.get_include_relations(),
...
}).await?;
internal_run_task (in crates/action-graph/src/action_graph_builder.rs) then executes dependents whenever the run-scope allows and the dependent passes the affected gate — which --include-relations lets a relation-marked dependent do:
let should_run_dependents =
!state.via_dependency && reqs.dependents.is_in_scope(state.depth);
...
if should_run_dependents { self.run_task_dependents(...).await?; }
So --downstream is simultaneously "spread affected onto dependents" and "run those dependents."
Concrete failure (publish → deploy CI)
Task graph: deploy deps container-publish deps build; build depends on a workspace library @scope/lib via a project edge. An app's container image bundles the library's built source, but that source is not in the app's own task inputs — only the graph edge connects them.
To make a library-only change mark an app's container-publish affected, the affected signal must travel from lib:build (upstream) down to app:container-publish — which requires --downstream deep --include-relations (the app's tasks are downstream dependents of lib:build in the task graph).
But then:
moon exec ':container-publish' --affected --downstream deep --include-relations
…also runs every :deploy, because deploy is a dependent of container-publish. A step meant only to build & push images also rolls out to production. (Confirmed on 2.5.1: dropping --include-relations stops :deploy from running — it's the relation-mark passing the affected gate that does it.)
Minimal repro
# app/moon.yml (app depends on project `lib`)
tasks:
build: { command: 'echo build' }
publish: { command: 'echo publish', deps: ['build'] }
deploy: { command: 'echo deploy', deps: ['publish'] }
# change only app's own build input
moon exec 'app:publish' --affected --downstream deep --include-relations
# → app:deploy ALSO runs, even though only :publish was requested
Why existing flags can't express the intent
--downstream none → the library change no longer marks the app's container-publish affected at all (defeats the purpose).
--downstream deep → marking works, but the run set now includes the target's dependents (:deploy).
--upstream / --include-relations tweaks don't help: the mark has to reach the target through a downstream traversal from the changed node, and that same scope drives run-expansion.
The internal model already keeps these separate (track_affected scopes vs RunRequirements scopes) — they're just wired to the same CLI inputs.
Proposed solution (any one of these)
- Separate marking scope from run scope — e.g.
--affected-upstream / --affected-downstream (or MOON_AFFECTED_UPSTREAM / MOON_AFFECTED_DOWNSTREAM) that widen only the affected-mark traversal, leaving --upstream / --downstream to control the run set. For CI publish I'd set marking = deep both directions, run = downstream:none, upstream:deep.
- Make
--include-relations marking-only — it decides whether a requested target counts as affected via graph relations, and does not by itself expand the run set to non-requested related tasks (those stay governed by --upstream / --downstream).
- A "gate requested targets only" mode — run exactly the tasks matching the target locator, gated by full-graph affected relations, plus their required task-deps for correctness — never their dependents.
Workaround
Target the leaf task and let the rest come in as upstream deps:
moon exec ':deploy' --affected --downstream deep --include-relations
deploy has no dependents, so downstream run-expansion adds nothing, while --upstream deep (the exec default) pulls container-publish / build in as forced deps — publish and deploy stay in one graph, with the publish-set equal to the deploy-set's own dependencies. This only works because :deploy is terminal; a non-leaf target (like :container-publish) has no clean equivalent.
Related
Environment
moon 2.5.1
Summary
On
moon run/moon exec,--upstream/--downstream(together with the marking--include-relationsenables) control two distinct things at once, with no way to separate them:There is no way to say "propagate affected along dependency edges so my requested target is correctly marked, but do not also pull that target's dependents into the run." The result: a deliberately-scoped
moon exec ':<task>'in CI runs tasks it was never asked to.Where they're coupled
In
crates/app/src/commands/exec.rs(build_action_graph), the sameupstream/downstreamvalues feed both the affected tracker and the run requirements:internal_run_task(incrates/action-graph/src/action_graph_builder.rs) then executes dependents whenever the run-scope allows and the dependent passes the affected gate — which--include-relationslets a relation-marked dependent do:So
--downstreamis simultaneously "spread affected onto dependents" and "run those dependents."Concrete failure (publish → deploy CI)
Task graph:
deploydepscontainer-publishdepsbuild;builddepends on a workspace library@scope/libvia a project edge. An app's container image bundles the library's built source, but that source is not in the app's own taskinputs— only the graph edge connects them.To make a library-only change mark an app's
container-publishaffected, the affected signal must travel fromlib:build(upstream) down toapp:container-publish— which requires--downstream deep --include-relations(the app's tasks are downstream dependents oflib:buildin the task graph).But then:
…also runs every
:deploy, becausedeployis a dependent ofcontainer-publish. A step meant only to build & push images also rolls out to production. (Confirmed on 2.5.1: dropping--include-relationsstops:deployfrom running — it's the relation-mark passing the affected gate that does it.)Minimal repro
Why existing flags can't express the intent
--downstream none→ the library change no longer marks the app'scontainer-publishaffected at all (defeats the purpose).--downstream deep→ marking works, but the run set now includes the target's dependents (:deploy).--upstream/--include-relationstweaks don't help: the mark has to reach the target through a downstream traversal from the changed node, and that same scope drives run-expansion.The internal model already keeps these separate (
track_affectedscopes vsRunRequirementsscopes) — they're just wired to the same CLI inputs.Proposed solution (any one of these)
--affected-upstream/--affected-downstream(orMOON_AFFECTED_UPSTREAM/MOON_AFFECTED_DOWNSTREAM) that widen only the affected-mark traversal, leaving--upstream/--downstreamto control the run set. For CI publish I'd set marking = deep both directions, run =downstream:none, upstream:deep.--include-relationsmarking-only — it decides whether a requested target counts as affected via graph relations, and does not by itself expand the run set to non-requested related tasks (those stay governed by--upstream/--downstream).Workaround
Target the leaf task and let the rest come in as upstream deps:
deployhas no dependents, so downstream run-expansion adds nothing, while--upstream deep(the exec default) pullscontainer-publish/buildin as forced deps — publish and deploy stay in one graph, with the publish-set equal to the deploy-set's own dependencies. This only works because:deployis terminal; a non-leaf target (like:container-publish) has no clean equivalent.Related
via_dependencyguard) fixed a different problem: downstream reflecting through upstream siblings. This report is about running the target's own dependents.moon cidoes not run dependents of affected targets across project boundaries #2499 and [bug]--upstream=noneremoves task ordering edges #2685 sit in the same problem space (affected + up/downstream onrun/ci), different facets.Environment
moon 2.5.1