Skip to content

[feature] Separate affected-mark propagation from run-set expansion on moon run/moon exec (--downstream conflates them) #2686

Description

@marcus-sa

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:

  1. Affected-mark propagation — how far the "affected" signal spreads across graph edges to decide whether a requested target is affected.
  2. 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)

  1. 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.
  2. 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).
  3. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions