Skip to content

Dependencies

depends_on says what must run before a task. You list the prerequisites above the task, and asgard runs them in order, runs groups of them at the same time where you ask for that, and makes sure each one runs only once no matter how many tasks need it. Dependencies are what turn a pile of commands into a pipeline.

The simplest form is one task that needs another:

class Tasks
  desc "Compile the project"
  def build = sh "rake build"

  depends_on :build
  desc "Run the test suite"
  def test = sh "rake test"
end
asgard test    # runs build, then test

Note

desc and depends_on both go above the def, in either order. depends_on always attaches to the very next method.


How It Works

When you run asgard <task>:

  1. Every .loki file loads and every dependency list is checked: unknown task names, cycles and a few other mistakes stop asgard before anything runs (see Startup Validation).
  2. The task's prerequisites run stage by stage. A stage is one task, or a group of tasks that run at the same time. The next stage starts when every task in the current one has finished.
  3. The task itself runs.

Each task runs at most once per asgard command, however many other tasks depend on it. A prerequisite that fails stops the whole run with a non-zero exit status.


Sequential Dependencies

Bare task names run one after another, left to right:

class Tasks
  desc "Compile the project"
  def build = sh "rake build"

  depends_on :build
  desc "Run the test suite"
  def test = sh "rake test"

  depends_on :test
  desc "Publish the gem"
  def release = sh "bundle exec rake release"
end
asgard release   # build → test → release

Several names in one depends_on also run in order:

depends_on :clean, :build, :test
desc "Clean, build, and test"
def package = sh "rake package"
asgard package   # clean → build → test → package

Parallel Dependencies

Wrap names in an array and they run at the same time. Asgard waits for the whole group before moving on:

class Tasks
  desc "Check code style"
  def lint = sh "bundle exec rubocop"

  desc "Run type checks"
  def typecheck = sh "bundle exec srb tc"

  depends_on [:lint, :typecheck]
  desc "Run tests (after lint and typecheck)"
  def test = sh "bundle exec rake test"
end
asgard test   # lint ∥ typecheck → test

Parallel tasks run in threads inside one asgard process. For shell commands and other work that waits on the outside world, that is real concurrency: two sh calls in two parallel tasks are two processes running at once. The output of parallel tasks interleaves on the terminal; examples/concurrent.loki in the repository shows this on purpose.


Mixed Sequential and Parallel

Mix bare names and arrays in one depends_on. Each element is a stage:

class Tasks
  desc "Install dependencies"; def setup  = sh "bundle install"
  desc "Check code style";     def lint   = sh "bundle exec rubocop"
  desc "Compile assets";       def build  = sh "rake assets:precompile"
  desc "Run tests";            def test   = sh "bundle exec rake test"
  desc "Post to Slack";        def notify = sh "curl $SLACK_WEBHOOK -d '{\"text\":\"done\"}'"

  # setup first, then lint and build together, then test, then notify
  depends_on :setup, [:lint, :build], :test, :notify
  desc "Full CI pipeline"
  def ci = puts "CI complete"
end
asgard ci

Execution stages of depends_on :setup, [:lint, :build], :test, :notify followed by the ci task stage 1 stage 2 (parallel) stage 3 stage 4 the task

<!-- stage 2 group outline -->
<rect x="160" y="60" width="140" height="230" rx="12" fill="#fbbf24" fill-opacity="0.08" stroke="#fbbf24" stroke-opacity="0.5" stroke-width="1" stroke-dasharray="4 3"/>

<!-- setup -->
<rect x="10" y="150" width="100" height="44" rx="8" fill="#2dd4bf" fill-opacity="0.22" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="60" y="178" text-anchor="middle">setup</text>

<!-- lint -->
<rect x="180" y="90" width="100" height="44" rx="8" fill="#2dd4bf" fill-opacity="0.22" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="230" y="118" text-anchor="middle">lint</text>

<!-- build -->
<rect x="180" y="210" width="100" height="44" rx="8" fill="#2dd4bf" fill-opacity="0.22" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="230" y="238" text-anchor="middle">build</text>

<!-- test -->
<rect x="350" y="150" width="100" height="44" rx="8" fill="#2dd4bf" fill-opacity="0.22" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="400" y="178" text-anchor="middle">test</text>

<!-- notify -->
<rect x="490" y="150" width="100" height="44" rx="8" fill="#2dd4bf" fill-opacity="0.22" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="540" y="178" text-anchor="middle">notify</text>

<!-- ci -->
<rect x="640" y="150" width="100" height="44" rx="8" fill="#fbbf24" fill-opacity="0.22" stroke="#fbbf24" stroke-width="1.5"/>
<text x="690" y="178" text-anchor="middle">ci</text>

<!-- connectors -->
<g fill="none" stroke="currentColor" stroke-opacity="0.6" stroke-width="1.5" marker-end="url(#deps-arrow)">
  <path d="M 110 172 C 145 172, 145 112, 178 112"/>
  <path d="M 110 172 C 145 172, 145 232, 178 232"/>
  <path d="M 280 112 C 315 112, 315 172, 348 172"/>
  <path d="M 280 232 C 315 232, 315 172, 348 172"/>
  <path d="M 450 172 L 488 172"/>
  <path d="M 590 172 L 638 172"/>
</g>

<!-- caption line -->
<g font-size="12" fill-opacity="0.65">
  <text x="230" y="312" text-anchor="middle">both finish before stage 3 starts</text>
</g>

Each element of depends_on is one stage. An array is a stage whose tasks run together.
setup
  ↓
lint ∥ build    (at the same time)
  ↓
test
  ↓
notify
  ↓
ci

Each Task Runs Once

A task that several other tasks depend on runs the first time it is needed and is skipped after that:

class Tasks
  desc "Install gems"
  def setup = sh "bundle install"

  depends_on :setup
  desc "Run tests"
  def test = sh "rake test"

  depends_on :setup
  desc "Check style"
  def lint = sh "rubocop"

  depends_on [:test, :lint]
  desc "Test and lint (setup runs once)"
  def ci = puts "done"
end
asgard ci   # setup once, then test ∥ lint, then ci

Here test and lint start together and both want setup. One of them runs it; the other waits until it has finished rather than running it a second time or starting early. The guarantee holds across parallel groups, across files and across subcommands: one asgard command, one run per task.

Transitive Dependencies

A prerequisite's own prerequisites run first, recursively:

class Tasks
  desc "Fetch gems"
  def setup = sh "bundle install"

  depends_on :setup
  desc "Compile assets"
  def build = sh "rake assets:precompile"

  desc "Check code style"
  def lint = sh "bundle exec rubocop"

  depends_on :build, :lint, :setup
  desc "Run the full pipeline"
  def ci = puts "Done."
end
asgard ci
setup          (as build's prerequisite)
  ↓
build
  ↓
lint
  ↓
(setup skipped: already done)
  ↓
ci

Tip

Declare a task where it logically belongs, as a prerequisite of the task that needs it. Naming it again further up is harmless (it runs once either way) but adds noise.


Dependencies Across Files

All .loki files reopen the same Tasks class, so a task in one file may depend on a task in another. Names are checked once every file has loaded, so the order in which the files load does not matter:

# test.loki
class Tasks
  depends_on :build        # defined in build.loki, which may load before or after this file
  desc "Test"
  def test = sh "rake test"
end
# build.loki
class Tasks
  desc "Compile"
  def build = sh "rake build"
end

What does depend on load order is a task name defined twice: the later definition silently replaces the earlier one, including its depends_on. See Task Files and Help & Diagnostics for how asgard --doctor finds that.


When a Dependency Fails

Asgard does not decide what a failing dependency means. You do, when you write the task. A dependency can fail in two ways, and the difference is entirely in how its body ends:

  • It ends the run. The dependency exits non-zero, aborts or raises. Nothing after it runs: not the task that depends on it, not anything further up the chain. Use this for a prerequisite that must succeed, where carrying on would be wrong or dangerous.
  • It reports. The dependency returns a value that says what happened (:fail, false, a count, a Hash of findings) and finishes normally. The task that depends on it runs and reads that value with dep_result, and decides for itself what to do about it.

Neither is the default for "failure". A test suite can be either: a gate in front of release, or one line in a report.

A Dependency That Must Succeed

Make the dependency stop the run when it fails. Any of these does it:

  • A failing sh or shebang ends asgard with the command's exit status. This is the usual case, and it needs no code: asgard release with a failing test suite exits with the test runner's status.
  • abort "message" prints the message to standard error and exits 1.
  • exit 2, or any other non-zero code, exits with that code.
  • raise Asgard::Error, "message" prints asgard: message and exits 1. Any other exception prints Ruby's usual message and backtrace, and exits 1.
class Tasks
  desc "Refuse to deploy from a dirty tree"
  def clean_tree
    abort "commit or stash your changes first" unless `git status --porcelain`.strip.empty?
  end

  desc "Refuse to deploy without credentials"
  def credentials
    exit 2 unless ENV["DEPLOY_TOKEN"]
  end

  depends_on :clean_tree, :credentials
  desc "Deploy to production"
  def deploy = sh "cap production deploy"
end
asgard deploy
# commit or stash your changes first
# (exit 1; credentials and deploy never ran)

examples/confirm.loki in the repository is a guard task built this way: it asks "Really run asgard reset_database?" and raises Asgard::Error on anything but yes. See Examples.

A Dependency That Reports

Return a value instead, and let the task that depends on it judge. A return value never stops anything: a dependency that returns :fail or false has finished as far as the pipeline is concerned, and the dependent runs. This is what you want when every check should run and be reported together, rather than the first failure hiding the rest.

The repository's own quality task works this way. Each *_check dependency runs its tool and returns :pass, :fail, :warn or :skip; none of them stops the run. quality runs after all of them, prints one line per check, and only then decides: it calls abort when any check returned :fail, so asgard quality still exits 1 for CI. The code is under Passing Results Between Tasks.

Returning :fail from the task you typed on the command line makes asgard exit 1, so a check run on its own (asgard test_check) still reports its result through the exit status.

Failure in a Parallel Group

When one task in a parallel group fails, asgard waits for every other task in the group to finish before reporting, so no subprocess is left running on its own. One failure is reported the same way it would be on its own: a failing command exits asgard with its status, an exception propagates. When more than one task fails, each is named on stderr and the run ends with an error listing them:

asgard: dependency 'lint' failed: exit 1
asgard: dependency 'typecheck' failed: RuntimeError: boom
asgard: parallel dependencies failed: lint, typecheck

A task that is waiting for a shared prerequisite in another thread does not run if that prerequisite fails. With ci depending on [test, lint] and both depending on setup, a failing setup stops test, lint and ci alike, and asgard exits with setup's status.


Passing Results Between Tasks

A task's return value is kept, and the task that depends on it can read it with dep_result. This is how a summary task collects what its prerequisites found. The example is the pattern the asgard repository uses for its own quality gates: every *_check task returns :pass, :fail, :warn or :skip, and quality prints a table:

class Tasks
  desc "Run the test suite"
  def test_check
    output = `bundle exec rake test 2>&1`
    File.write("test_output.txt", output)
    $?.success? ? :pass : :fail
  end

  desc "Check code style"
  def rubocop_check
    output = `bundle exec rubocop 2>&1`
    File.write("rubocop_output.txt", output)
    $?.success? ? :pass : :fail
  end

  depends_on [:test_check, :rubocop_check]
  desc "Run every check in parallel, then report"
  def quality
    dep_results.each { |name, status| puts "  #{status.to_s.upcase.ljust(5)} #{name}" }
    failed = dep_results.values.count(:fail)
    abort "#{failed} check(s) failed" unless failed.zero?
    puts "All checks passed."
  end
end
asgard quality
  PASS  test_check
  FAIL  rubocop_check
1 check(s) failed
  • dep_result(:test_check) is the return value of one direct dependency.
  • dep_results is a Hash of every direct dependency's name to its return value.

Both read the dependencies of the task that is running, not of tasks further down the chain. A check that uses backticks or system rather than sh can report its result as a value instead of ending asgard; see Exit on Failure.

Parallel dependencies do not share instance variables

Tasks in one parallel group each run on their own copy of the task object, so an @ivar set by a parallel dependency is not visible to the task that depends on it. Sequential dependencies do share it, which is why the difference can surprise you when a depends_on :a, :b later becomes depends_on [:a, :b]. Pass values with dep_result instead; examples/bad.loki in the repository shows an @ivar coming back nil while dep_result carries the value.


A Dependency With Arguments

A task that takes required arguments cannot be a dependency, because depends_on runs each dependency with none. When the arguments are fixed, write a small task that calls it with them, and depend on that:

desc "compile TARGET [ARCH]", "Compile one target"
def compile(target, arch = "arm64") = sh "make #{target} ARCH=#{arch}"

def compile_release = compile("release", "x86_64")
def compile_debug   = compile("debug")

depends_on :setup, [:compile_release, :compile_debug], :package
desc "Ship it"
def ship = sh "upload pkg/*"

compile_release and compile_debug take no arguments, so they are ordinary dependencies: each runs once, they run side by side in their parallel group, and ship can read what they returned with dep_result(:compile_release). Inside each one, compile is just a method call. compile is still a task of its own, and asgard compile debug arm64 runs it from the command line as before.

Neither wrapper has a desc, so they are hidden tasks: they do not appear in asgard help, but asgard compile_release runs one and asgard --doctor lists them. Give one a desc when it deserves a place in the help text.

One wrapper per argument set keeps this honest for a handful of combinations. When the arguments are only known while the task runs, call the task as a method from the task body instead, compile(target), and accept that the call is not a dependency: it runs every time, in sequence, and has no entry in dep_results.


Startup Validation

Before any task runs, asgard checks every dependency list in every task class and stops with one line on the first problem. No backtrace, exit 1.

Circular dependencies

class Tasks
  depends_on :b
  desc "Task A"; def a = puts "a"

  depends_on :a
  desc "Task B"; def b = puts "b"
end
asgard a
# asgard: circular dependency — topological sort failed: [:a, :b]

A dependency that is not a task

asgard ci
# asgard: undefined task(s) in depends_on: ghost_check (needed by ci)

A private method is not a task, so depends_on :my_helper is undefined even though the method exists. Make it a task or call it from the task body. Each missing dependency names the task or tasks that declared it.

A depends_on with nothing after it

A depends_on at the end of a class body, or followed only by private methods, has no task to attach to:

asgard
# asgard: depends_on(build, test) declared without a following task definition

A dependency that needs arguments

A task with required positional parameters cannot be a dependency, because nothing would supply them:

desc "greet NAME", "Greet NAME"
def greet(name) = puts "Hello, #{name}"

depends_on :greet
desc "Welcome"
def welcome = puts "welcome"
asgard welcome
# asgard: task 'greet' has 1 required argument(s) and cannot be used as a dependency

Give the parameter a default (def greet(name = "World")), read the value from an option or environment variable, or depend on a wrapper task that supplies the arguments; see A Dependency With Arguments.

asgard --doctor reports the same problems without stopping at the first one; see Help & Diagnostics.


Dynamic Dependencies

A plain depends_on list is fixed when you write it. Sometimes the list has to be discovered: "every task whose name ends in _check", wherever it is defined, including a file that is only imported on some machines. For that, give depends_on a block, or a lambda, instead of a list. It is called once, after every .loki file has loaded, and must return the same shape a plain list would be:

depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
desc "Run every *_check task in parallel, then report"
def quality
  dep_results.each { |name, status| puts "#{status} #{name}" }
end

depends_on -> { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
desc "The same thing, written as a lambda"
def quality2 = puts "done"

all_commands.keys is the list of every task name defined so far, as strings. The block above returns [[:a_check, :b_check, :c_check]]: an array with one element, an array of three tasks, which is one parallel stage, exactly like writing depends_on [:a_check, :b_check, :c_check].

The Shape of the Result

The block must return an Array of stages. Each stage is a Symbol or String (a sequential task) or an Array of Symbols or Strings (a parallel group). Nothing nests deeper than that. A mixed pipeline is written the same way the plain form is:

depends_on { [:setup, %i[a_check b_check], :teardown] }
desc "setup, then a_check and b_check in parallel, then teardown"
def full_cycle = puts "full_cycle: done"

Use braces for a one-line block and do ... end when the block has several statements. Either way the last expression is the result:

depends_on do
  all_checks  = all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)
  slow_checks = %i[integration_check]

  [:setup, all_checks - slow_checks, :teardown]
end
desc "Every check except the slow ones"
def smoke = puts "smoke: done"

The shape is checked as soon as the block returns. A wrong shape stops asgard with a message naming the task and the offending value, instead of failing later with an obscure error. The common mistake is one pair of brackets too many:

depends_on { [[all_commands.keys.grep(/_check\z/).map(&:to_sym)]] }   # one [ ] too many
asgard: depends_on proc/block for 'quality' returned invalid dependency [:a_check, :b_check, :c_check] (Array) in a parallel group; expected a Symbol or String

Other shape errors read the same way, for example returned invalid stage 123 (Integer); expected a Symbol, String, or Array of them. If the block itself raises, asgard reports that too: asgard: depends_on proc for 'quality' raised RuntimeError: boom.

What the Block Can See

The block is written in the Tasks class body, so inside it self is that class: all_commands and the other class-level words work bare, with no prefix, even though the block is not called until later. Once it resolves, the result replaces the block, so cycle detection, the undefined-task check and the arguments check all run against the resolved list, exactly as for a plain one.

depends_on takes a list or a block, never both; combining them is an error.

Warning

The block runs before the command line is parsed, so options, verbose? and debug? inside it always see their defaults, never what was typed on this run. Keep the block to shaping the dependency list.

Tip

Reach for the block only when the list cannot be known until every file has loaded. A fixed list is clearer written out. examples/depends_on_block/good/.loki and bad/.loki in the repository are two small projects showing the block form working and the shape error; see Examples.


Dependencies Inside Subcommands

depends_on works inside a subcommand class exactly as at the top level. It names that class's own tasks:

class DBCommands < Tasks
  desc "Run migrations"
  def migrate = sh "rails db:migrate"

  desc "Load seed data"
  def seed = sh "rails db:seed"

  depends_on :migrate, :seed
  desc "Migrate then seed"
  def reset = puts "Done."
end

class Tasks
  desc "db SUBCOMMAND", "Manage the database"
  subcommand "db", DBCommands
end
asgard db reset   # migrate → seed → reset

Each subcommand class has its own dependency graph, validated at startup like the top-level one. See Subcommands.


Steps Inside One Task

depends_on orders whole tasks. Sometimes the graph belongs inside a single task: three commands where two can run at the same time, the third needs their output, and a failure in any of them should stop the rest with a message that says which step failed. A task can do that with the simple_flow gem (gem install simple_flow), which runs a dependency graph of steps and passes a result from step to step.

require "simple_flow"

class Tasks
  desc "Two steps in parallel, a third merges their output"
  def fan_out
    a = ->(r) { capture(r, :a, "echo alpha; sleep 1") }
    b = ->(r) { capture(r, :b, "echo beta") }
    c = ->(r) { capture(r, :c, "echo merged: #{r.context[:a].strip} + #{r.context[:b].strip}") }

    pipeline = SimpleFlow::Pipeline.new(concurrency: :threads) do
      step :a, a, depends_on: :none
      step :b, b, depends_on: :none
      step :c, c, depends_on: [:a, :b]
    end

    result = pipeline.call_parallel(SimpleFlow::Result.new(nil))
    raise Asgard::Error, "pipeline halted: #{result.errors}" unless result.continue?

    puts result.context[:c]
  end

  private

  # Runs +command+, keeps its output in the result under +name+, and
  # halts the pipeline with an error when the command fails.
  def capture(result, name, command)
    out = IO.popen(command, err: %i[child out], &:read)
    return result.with_context(name, out) if $?.success?

    result.with_error(name, "exit #{$?.exitstatus}: #{out.strip}").halt
  end
end
asgard fan_out
merged: alpha + beta

a and b run side by side; c runs once both have finished and reads their output from the result. When a step halts, the steps after it do not run, and the task raises Asgard::Error with the step's name and message, so asgard prints one line and exits 1.

Two rules make this work inside a task:

  • Never call sh inside a step. sh reports failure by exiting asgard, and from inside a step that happens before the pipeline can see the failure: the other steps are cut off and nothing after the pipeline runs. Run the command with IO.popen as capture does, or with backticks or system, and turn a bad exit status into with_error and halt. A step usually needs the output anyway, to pass it on.
  • Write the steps as lambdas in the task body, then hand them to step. Inside the Pipeline.new block, self is the pipeline, so a lambda written there cannot reach the task's helpers or options. A lambda made in the task body keeps the task as self.

examples/simple_flow/.loki in the repository is this task plus a halting task whose middle step fails; see Examples.


Summary

Form Meaning
depends_on :a Run a first
depends_on :a, :b Run a, then b
depends_on [:a, :b] Run a and b at the same time
depends_on :a, [:b, :c], :d a, then b with c, then d
depends_on { [...] } or depends_on -> { [...] } Compute the list after every file has loaded
dep_result(:a) In the task body: what a returned
dep_results In the task body: every direct dependency's result, by name

Every task runs once per asgard command. A failing command or a raised error stops the run; a return value does not.