Examples¶
The asgard repository ships working .loki files in its examples/ directory, one for each feature, plus the gem's own task files at the repository root, which are a real multi-file project in daily use. This page says what each one shows and how to run it. Copy whatever is useful into your own project.
Running the Examples Directory¶
examples/ has its own .loki, so it is a project in its own right:
Its .loki loads everything else explicitly:
# examples/.loki
import "*.loki" # every sibling task file, alphabetically
import "subdir/import_demo.loki" # one file from a subdirectory
Four examples are separate projects with their own .loki and must be run from their own directory: depends_on_block/good, depends_on_block/bad, simple_flow and watcher_examples. subdir/ has a .loki of its own as well, so it works both as part of examples/ and on its own.
Note
concurrent.loki exists to be run, not read: its point is output that changes from run to run.
kitchen_sink.loki¶
Every Thor word and most of asgard's, in one file. Task names avoid the gem's own (test, build, quality, install, release) so the file can sit beside them.
@@app_nameand@@build_dirclass variables shared by every taskprivatecomputed values (version,sha,branch) with memoizationclass_option :dry_runandclass_option :envwithenum, on every taskdefault_task :greetandmapaliases (g,ck,rp,pl)- A positional parameter with a default (
hello NAME) - All five option types (
:string,:boolean,:numeric,:array,:hash) oncompile required: true,enum:andbanner:ondeploylong_descwith\x5line breaks onreport- Sequential, parallel and mixed
depends_on(spec,check,pipeline) debug?andverbose?onstatusshwith one line and with a heredoc (env_info,bootstrap)- A
no_commandshelper and aprivatehelper
asgard # the default task, greet
asgard hello Alice
asgard compile --jobs 4 --tags debug release --defines VERSION:2 MODE:fast
asgard deploy --strategy rolling
asgard deploy # No value provided for required options '--strategy'
asgard help report # the long_desc
asgard pipeline # check → compile ∥ spec → pack → pipeline
asgard status --verbose --debug
See Defining Tasks, Options & Flags and Dependencies.
server_subcommands.loki¶
A subcommand group, asgard server ..., in a class that inherits from Tasks:
ServerCommands < Tasksregistered withsubcommand "server", ServerCommands- Per-command options (
--port,--daemon,--workers,--log,--force,--wait) - A
helper(server_port) that reads the port saved bystartsostopandrestartuse the same one depends_on :stopinside the group, sorestartstops first
asgard server # the group's own help
asgard server start -p 4000 --workers 4 --daemon
asgard server status
asgard server stop --force
asgard server restart # stop → restart, on the saved port
See Subcommands.
db_subcommands.loki¶
A second group, asgard db ..., with a longer dependency chain:
migrate [VERSION],rollback [STEPS],seed [FILE]with optional positional parametersdepends_on :rollback, :migrate, :seedonresetlong_descandenumvalidation inside a subcommand class
asgard db migrate 20240101120000 --dry-run
asgard db rollback 3
asgard db seed --env staging
asgard db reset # rollback → migrate → seed → reset
asgard db console --env staging
concurrent.loki¶
Proof that a parallel stage really runs at the same time. Three workers each print a letter ten times with random pauses; finish depends on all three.
depends_on :start, [:worker_a, :worker_b, :worker_c]
desc "Print end marker after all workers complete"
def finish = puts "\nfini - the end of concurrent task demo"
starting demo of concurrent task execution ...
ABCBACBACBABCBACBACBCABCABACBA
fini - the end of concurrent task demo
The letters interleave differently every run. The file sets $stdout.sync = true so each print appears at once. See Parallel Dependencies.
do.loki¶
Asgard runs one task per command line. This example adds a do task that runs several, the way rake a b c or just a b c does. It is an ordinary user task, so you can copy it into any .loki:
require "shellwords"
class Tasks
# Not a task (a class method), so it can be tested on its own:
# Tasks.do_steps("lint, hello Alice") #=> [["lint"], ["hello", "Alice"]]
def self.do_steps(line)
line.include?(",") ? line.split(",").map { |step| Shellwords.split(step) }.reject(&:empty?)
: Shellwords.split(line).map { |word| [word] }
end
desc "do STEPS...", "Run several tasks in sequence, e.g. `asgard do hello Alice, farewell Bob --formal`"
stop_on_unknown_option! :do
def do(*words)
steps = self.class.do_steps(words.join(" "))
raise Asgard::Error, "do: no tasks given" if steps.empty?
steps.each do |name, *args|
raise Asgard::Error, "do: unknown task '#{name}'" if name == "do" || !self.class.all_commands.key?(name)
# Each step is parsed as if typed on its own command line.
return :fail if self.class.send(:dispatch, nil, [name, *args], nil, shell: shell) == :fail
end
end
end
Steps are comma-separated so a step can carry its own arguments and options; with no commas, every word is a task.
- Each step's arguments and options work exactly as they would alone.
- The sequence stops at the first failure: a failing
shexits asgard, and a step that returns:failmakesdoreturn:fail(exit 1). - Every task runs at most once per asgard invocation, so
asgard do hello Alice, hello Bobgreets only Alice, and a dependency shared by several steps runs once.
confirm.loki¶
A guard: list confirm first in a dangerous task's depends_on and the task only runs after you answer yes. Also an ordinary user task, worth copying:
class Tasks
# Not a task, so it can be tested on its own:
# Tasks.confirmed?("y", yes_env: nil) #=> true
def self.confirmed?(answer, yes_env:)
return true if yes_env && !yes_env.empty?
%w[y yes].include?(answer.to_s.strip.downcase)
end
desc "Ask before running the task that depends on this", hide: true
def confirm
command = "asgard #{ARGV.join(' ')}"
yes_env = env(:asgard_yes, "")
return if self.class.confirmed?(nil, yes_env:)
raise Asgard::Error, "refusing `#{command}`: no terminal to confirm on (set ASGARD_YES=1)" unless $stdin.tty?
answer = ask("Really run `#{command}`? [y/N]")
raise Asgard::Error, "aborted `#{command}`" unless self.class.confirmed?(answer, yes_env:)
end
depends_on :confirm
desc "Pretend to drop and recreate the database (demo for confirm)"
def reset_database
puts "Database reset."
end
end
asgard reset_database
# Really run `asgard reset_database`? [y/N] n
# asgard: aborted `asgard reset_database` (exit 1)
ASGARD_YES=1 asgard reset_database # no question: scripts and CI
# Database reset.
Points the example makes:
- Answering no raises
Asgard::Error, which stops everything that has not run yet. Returning:failwould not: a dependency's return value never stops the task that depends on it. - A dependency does not know which task asked for it, so the question shows the whole command line.
- With no terminal to ask on (a schedule, the watcher, a pipe) it refuses unless
ASGARD_YESis set, rather than hanging. hide: truekeepsconfirmout ofasgard help.- It runs once per invocation, so
asgard do reset_database, drop_cacheasks once for the whole line. Keep it in its own first stage: independs_on [:confirm, :backup],backupruns while the question waits.
ASGARD_YES is the example's own variable, not something asgard reads. See When a Dependency Fails.
env_usage.loki and .env¶
Reading variables with env after loading a dotenv file:
class Tasks
# loki_up finds .env in this directory or any ancestor, so the task
# works from any subdirectory.
dotenv loki_up(".env") || ".env"
desc "Print environment variable values resolved via env()"
def env_demo
puts <<~OUT
APP_NAME : #{env(:app_name)}
PORT : #{env(:port)}
API_KEY : #{env(:api_key)}
DATABASE_URL : #{env(:database_url)}
LOG_LEVEL : #{env(:log_level, "info")}
OUT
end
end
# examples/.env
APP_NAME=my_app
PORT=4000
API_KEY=secret-key-abc123
DATABASE_URL=postgres://localhost/my_app_dev
APP_NAME : my_app
PORT : 4000
API_KEY : secret-key-abc123
DATABASE_URL : postgres://localhost/my_app_dev
LOG_LEVEL : info
LOG_LEVEL is not in .env, so env(:log_level, "info") returns the default. See Environment Variables.
subdir/¶
Three files that show import across a directory boundary and import_up back out of one:
| File | What it does |
|---|---|
subdir/import_demo.loki |
Defines subdir_task; loaded by examples/.loki with import "subdir/import_demo.loki" |
subdir/.loki |
Makes subdir/ a project of its own when you run asgard from inside it; loads import_up_demo.loki |
subdir/import_up_demo.loki |
Calls import_up "env_usage.loki", which walks up and finds examples/env_usage.loki without a hard-coded relative path |
cd examples; asgard subdir_task # Loaded from examples/subdir/import_demo.loki
cd examples/subdir; asgard env_demo # found one directory up by import_up
See Task Files.
depends_on_block/good/ and depends_on_block/bad/¶
Two one-file projects for the block form of depends_on, which computes the dependency list after every file has loaded. Run each from its own directory.
good/.loki defines a_check, b_check, c_check and three tasks that gather them:
depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
desc "Run every *_check task in parallel, then report"
def quality = puts "quality: all *_check tasks passed"
depends_on { [:setup, %i[a_check b_check], :teardown] }
desc "setup, then a_check+b_check in parallel, then teardown"
def full_cycle = puts "full_cycle: done"
depends_on do
all_checks = all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)
slow_checks = %i[c_check]
[:setup, all_checks - slow_checks, :teardown]
end
desc "setup, then every *_check except the slow ones, then teardown"
def smoke = puts "smoke: done"
bad/.loki wraps the list in one Array too many, [[ ... ]]. The result is checked before any task runs:
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
See Dynamic Dependencies.
simple_flow/¶
A one-file project that runs a simple_flow pipeline inside a single task: a graph of steps, two of which run at the same time, with data passed from step to step. It needs the gem (gem install simple_flow) and runs from its own directory.
halting is the same graph with a failing middle step. The last step never runs, and the task turns the pipeline's error into an Asgard::Error:
The file's capture helper is the part to copy: it runs a command with IO.popen, keeps the output in the result, and halts the pipeline on a bad exit status instead of calling sh, which would exit asgard from inside the step. See Steps Inside One Task.
bad.loki¶
Why an instance variable is the wrong way to pass results out of parallel dependencies, and why dep_result exists. Four workers run in one parallel stage; each sets @hits to a thousand and also returns it:
Parallel dependencies each run on their own copy of the task object, so the @hits each worker sets never reaches bad_race. The return values do: read one with dep_result(:racer_1) or all of them with dep_results. See Passing Results Between Tasks.¶
watcher_examples/¶
A small project whose tasks run when its files change: four watch declarations covering a glob, a directory with only:, ignore: and env:, an events: filter with debounce:, and a second watch of one task with as:. The lib/, bin/, docs/ and data/ directories hold one placeholder file each so there is something to edit at once.
cd examples/watcher_examples
asgard watcher start
echo '# a change' >> lib/greeter.rb
asgard watcher log
asgard watcher list
asgard watcher stop
Every task also runs by hand, when changes is empty. Its README.md walks through the whole session with expected output; so does Watched Files.
The asgard Repository Itself¶
The repository root is a multi-file project that builds, tests and releases the gem. It is the best example of a .loki layout in real use.
.loki¶
The entry point: imports, shared values, a header built from a helper, a watch, and a default_task.
import "quality.loki"
import "quality_rails.loki" if ENV["RAILS_ROOT"] # a conditional import
import "gem_tasks.loki"
import "git.loki"
import "doc_tasks.loki"
import "xyzzy.loki" # a deliberate override, for --doctor
class Tasks
@@project ||= "asgard".freeze
@@project_desc ||= "CLI-task runner"
watch :test_check, paths: %w[lib test], only: /\.rb\z/ # rerun the tests on save
desc "xyzzy" # redefines xyzzy.loki's task, for --doctor
def xyzzy = puts "everything in Ruby metaprogramming is magic"
helper(:project_version) do
@@project_version ||= loki_up.dirname.join("lib/#{@@project}/version.rb").read.match(/VERSION\s*=\s*"([^"]+)"/)[1].freeze
end
default_task :quality
header <<~HEAD
Project: #{@@project} (v#{project_version}) - #{@@project_desc}
Root Dir: #{loki_up.parent}
Default task: #{default_task}
HEAD
end
quality.loki¶
The quality gates, and the convention that ties them together. Every gate is a task named *_check that writes its full output to <gate>_output.txt, reports one summary line, and returns a status: :pass, :fail, :warn (ran, non-blocking advice) or :skip (the tool is not installed). quality discovers every *_check task with a block depends_on, runs them all in parallel, reads every result at once with dep_results, and prints a summary table; it aborts only when a gate returned :fail. The first stage of its dependency list is quality_board, a hidden task that draws one spinner row per gate when standard output is a terminal, so each gate fills in its own row as it finishes; without a terminal, each gate prints its summary line instead.
# Every *_check task, in the order the summary lists them.
def self.quality_checks = all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)
desc "Run every *_check quality gate task in parallel"
depends_on -> { [:quality_board, quality_checks] }
def quality
results = dep_results.except(:quality_board).sort.to_h { |name, status| [quality_gate_label(name.to_s), status] }
print_quality_summary(results, rows: QUALITY_BOARD.empty?)
end
Most gates are one call to a run_gate helper (defined in a no_commands block) that runs a command, saves its output, picks the summary line with a Regexp and turns the exit status into :pass or the gate's failure status:
desc "Report code smells with Reek (advisory: warns, never fails)"
def reek_check = run_gate(:reek, "Reek", "bundle exec reek lib", summary: /total warnings?/, on_fail: :warn)
Because a *_check task returns :fail rather than exiting, every gate runs to the end and the summary shows all failures at once. Run alone (asgard rubocop_check), a gate that returns :fail still makes asgard exit 1. Piped, or anywhere standard output is not a terminal, the run looks like this:
Tests: 412 runs, 1193 assertions, 0 failures, 0 errors, 0 skips (see test_output.txt)
RuboCop: 61 files inspected, no offenses detected (see rubocop_output.txt)
...
============================================================
Quality Gate Summary
============================================================
[PASS] Tests + Coverage
[PASS] RuboCop
[SKIP] Typos
...
------------------------------------------------------------
9 pass, 1 skip
============================================================
All quality gates passed.
gem_tasks.loki¶
console, build (depends on quality), install (depends on build) and release (runs only on main, then depends on build, asks for confirmation unless --yes, refuses a dirty working tree or an existing tag). A plain sh sequence does the work; project_version comes from the helper in .loki.
git.loki¶
Three one-liners: push, pull (fast-forward only), fetch. The smallest useful task file.
doc_tasks.loki¶
A subcommand group, asgard doc ..., that works with an MkDocs site or a Bridgetown site. Each task first calls the private doc_engine helper, which looks in the repo root for mkdocs.yml or bridgetown.config.yml, sets the engine and the commands to run for it, and stops with an error when neither file is there. open depends on serve, which depends on build, which depends on install. The server starts as a background job and asgard returns at once; when the job is already running it is not started again, and build skips a site that is newer than every file in its source folder and its config file. open waits for the server to answer, then opens the site, http://localhost:8000/ for MkDocs or http://localhost:4000/ for Bridgetown, in the default browser, or in Google Chrome with --chrome. The commands run from the repo root, so the tasks work from any subdirectory.
class DocCommands < Tasks
default_task :open
# Each engine's config file, its default source and output folders, the
# commands the doc tasks run for it, and the port its server listens on
# by default.
DOC_ENGINES = {
mkdocs: { config: "mkdocs.yml", source: "docs", output: "site",
build: "mkdocs build", serve: "mkdocs serve", port: 8000 },
bridgetown: { config: "bridgetown.config.yml", source: "src", output: "output",
build: "bin/bridgetown build", serve: "bin/bridgetown start", port: 4000 },
}.freeze
# Seconds `doc open` waits for a server that has just started to answer.
SERVER_WAIT = 30
desc "Install the documentation engine (mkdocs with Homebrew, Bridgetown with bundle install)"
def install
case doc_engine
when :mkdocs then install_mkdocs
when :bridgetown then Dir.chdir(@doc_root) { sh "bundle install" }
end
end
desc "Build the documentation site, unless it is up to date"
depends_on :install
def build
doc_engine
return puts "doc: #{@output.relative_path_from(@doc_root)} is up to date" if built?(@output, [@config, @source])
Dir.chdir(@doc_root) { sh @build_command }
end
# A serve job that is already running is not started again: the call
# prints "serve-1 already running" and returns.
desc "Serve the documentation as a background job (see `asgard jobs`)"
depends_on :build
def serve
doc_engine
Dir.chdir(@doc_root) { sh @serve_command, background: true }
end
desc "Open the served documentation in the default browser"
option :chrome, type: :boolean, default: false, desc: "Use Google Chrome instead of the default browser"
depends_on :serve
def open
doc_engine
abort "doc: nothing answered at #{@doc_url} (see `asgard jobs log serve-1`)" unless server_up?(@port, SERVER_WAIT)
sh browser_command(@doc_url, chrome: options.chrome?)
end
private
# Finds the engine whose config file is in +root+ (the repo root by
# default), sets @engine, @doc_root, @config, @source, @output,
# @build_command, @serve_command, @port and @doc_url, and returns @engine.
# Aborts when neither config file is there.
def doc_engine(root = loki_up.dirname)
@engine, settings = DOC_ENGINES.find { |_, engine| root.join(engine[:config]).file? }
abort "doc: found neither mkdocs.yml nor bridgetown.config.yml in #{root}" unless @engine
@doc_root = root
@config, @source, @output = settings.values_at(:config, :source, :output).map { root.join(it) }
@build_command = settings[:build]
@serve_command = settings[:serve]
@port = settings[:port]
@doc_url = "http://localhost:#{@port}/"
@engine
end
# true when +output+'s index.html is newer than every file in +sources+
# (files, or folders searched all the way down).
def built?(output, sources)
index = output.join("index.html")
return false unless index.file?
files = sources.flat_map { |s| s.directory? ? s.glob("**/*").select(&:file?) : [s] }
files.all? { it.mtime <= index.mtime }
end
# true once something accepts connections on localhost:+port+; false
# when nothing has within +wait+ seconds.
def server_up?(port, wait)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
begin
Socket.tcp("localhost", port, connect_timeout: 1).close
true
rescue SystemCallError
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
sleep 0.25
retry
end
end
# The macOS command that opens +url+ in the default browser, or in Google
# Chrome when +chrome+ is true.
def browser_command(url, chrome: false)
chrome ? "open -a 'Google Chrome' #{url}" : "open #{url}"
end
def install_mkdocs
mkdocs = `which mkdocs`.strip
return puts "mkdocs: #{mkdocs}" unless mkdocs.empty?
sh "brew install mkdocs"
end
end
class Tasks
desc "doc SUBCOMMAND", "Install, build and serve the documentation (MkDocs or Bridgetown)"
subcommand "doc", DocCommands
end
asgard doc serve # started serve-1 (pid 48213), log: ...
asgard doc open # the site in the default browser
asgard doc # the same: open is the group's default task
asgard doc open --chrome # the site in Google Chrome
asgard jobs # lists it
asgard jobs log serve-1 # what the server has printed so far
asgard jobs stop serve
The group's install and build don't clash with the top-level ones in gem_tasks.loki: inside DocCommands they replace the inherited tasks, and asgard install is unchanged. To support another engine, add its config file and commands to DOC_ENGINES, plus an install branch. See Subcommands and Background Jobs.
xyzzy.loki¶
Defines a xyzzy task; .loki imports the file and then, further down, defines xyzzy again. The first definition is silently dead, and asgard --doctor says so:
.loki
xyzzy .loki:26 active — redefines xyzzy.loki:6
xyzzy.loki
xyzzy xyzzy.loki:6 OVERRIDDEN by .loki:26 — never callable
See Help & Diagnostics.
quality_rails.loki¶
Rails-only gates (brakeman_check, rails_best_practices_check, active_record_doctor_check), imported by .loki only when RAILS_ROOT is set. Nothing else needs to change for quality to pick them up: its block depends_on finds every *_check task that is loaded.
Summary¶
| Example | Shows |
|---|---|
kitchen_sink.loki |
Every option type, long_desc, map, default_task, class_option, all depends_on shapes, helpers |
server_subcommands.loki |
A subcommand group with options, a helper, depends_on inside the group |
db_subcommands.loki |
A subcommand group with a dependency chain, enum, long_desc |
concurrent.loki |
A parallel stage really running at once |
do.loki |
Several tasks from one command line, with per-step arguments |
confirm.loki |
A guard dependency that asks before a dangerous task |
env_usage.loki |
dotenv, loki_up(".env") and env with defaults |
subdir/ |
import from a subdirectory, import_up to an ancestor |
depends_on_block/good, bad |
The block form of depends_on, and its shape error |
bad.loki |
Why parallel tasks must not share instance variables |
watcher_examples/ |
Four watch declarations and the changes accessor |
| repository root | A real multi-file project: *_check gates, dep_result, conditional imports and tasks, a background job, an override caught by --doctor |