asgard 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.loki +7 -1
- data/.reek.yml +42 -0
- data/CHANGELOG.md +60 -4
- data/CLAUDE.md +8 -2
- data/README.md +77 -2
- data/doc_tasks.loki +21 -0
- data/docs/api.md +28 -5
- data/docs/changelog.md +1 -132
- data/docs/dependencies.md +18 -7
- data/docs/index.md +1 -0
- data/docs/schedule.md +101 -0
- data/docs/shell.md +30 -1
- data/docs/tasks.md +40 -0
- data/examples/depends_on_block/bad/.loki +51 -0
- data/examples/depends_on_block/good/.loki +83 -0
- data/examples/kitchen_sink.loki +13 -0
- data/lib/asgard/base/dependency_graph.rb +43 -7
- data/lib/asgard/base/task_dsl.rb +1 -2
- data/lib/asgard/errors.rb +7 -0
- data/lib/asgard/schedule/commands.rb +174 -0
- data/lib/asgard/schedule/declaration.rb +176 -0
- data/lib/asgard/schedule/launchd.rb +183 -0
- data/lib/asgard/schedule/systemd.rb +199 -0
- data/lib/asgard/schedule.rb +44 -0
- data/lib/asgard/shell.rb +21 -8
- data/lib/asgard/tasks.rb +12 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard.rb +9 -4
- data/mkdocs.yml +1 -0
- data/quality.loki +7 -2
- data/quality_rails.loki +3 -2
- metadata +14 -3
data/docs/dependencies.md
CHANGED
|
@@ -236,9 +236,9 @@ Because `*.loki` files are loaded alphabetically when `import "*.loki"` is used,
|
|
|
236
236
|
|
|
237
237
|
---
|
|
238
238
|
|
|
239
|
-
## Dynamic Dependencies (Proc Form)
|
|
239
|
+
## Dynamic Dependencies (Proc / Block Form)
|
|
240
240
|
|
|
241
|
-
`depends_on` normally takes a fixed list, recorded the moment the `def` right after it is encountered — which is why load order matters, as above. Pass a `Proc` or lambda instead, and that list is computed *later*, after every `.loki` file has finished loading, rather than at the point `depends_on` itself is evaluated:
|
|
241
|
+
`depends_on` normally takes a fixed list, recorded the moment the `def` right after it is encountered — which is why load order matters, as above. Pass a `Proc` or lambda instead — or, equivalently, a block — and that list is computed *later*, after every `.loki` file has finished loading, rather than at the point `depends_on` itself is evaluated:
|
|
242
242
|
|
|
243
243
|
```ruby
|
|
244
244
|
depends_on -> { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
|
|
@@ -246,22 +246,33 @@ desc "Run every *_check quality gate task in parallel"
|
|
|
246
246
|
def quality
|
|
247
247
|
# ...
|
|
248
248
|
end
|
|
249
|
+
|
|
250
|
+
depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
|
|
251
|
+
desc "Same thing, written as a block"
|
|
252
|
+
def quality2
|
|
253
|
+
# ...
|
|
254
|
+
end
|
|
249
255
|
```
|
|
250
256
|
|
|
251
|
-
This solves exactly the "load order matters" problem from the previous section: a plain array can only name tasks that already exist in `.loki` files loaded *before* this one. A Proc is resolved once every file has loaded, so it can safely reference a task defined in a file that hasn't been imported yet at the point `depends_on` is written — including one that only exists conditionally, e.g. a Rails-specific task file imported with `import "quality_rails.loki" if defined?(Rails)`.
|
|
257
|
+
This solves exactly the "load order matters" problem from the previous section: a plain array can only name tasks that already exist in `.loki` files loaded *before* this one. A Proc/block is resolved once every file has loaded, so it can safely reference a task defined in a file that hasn't been imported yet at the point `depends_on` is written — including one that only exists conditionally, e.g. a Rails-specific task file imported with `import "quality_rails.loki" if defined?(Rails)`. `depends_on` accepts task arguments *or* a block, never both — combining them raises `Asgard::Error`.
|
|
258
|
+
|
|
259
|
+
**Shape:** the Proc/block must return exactly what the plain-array form would receive as its splat arguments — an array of stages, each a `Symbol`/`String` (sequential) or an `Array` of `Symbol`/`String` (parallel group), nested no deeper than that. The example above returns `[[:a_check, :b_check, :c_check]]`: one stage, containing every matching task, all running in parallel — the same shape as `depends_on [:a_check, :b_check, :c_check]`. This shape is validated once the result comes back — a bad return value (wrong type, a stage that isn't a Symbol/String/Array, a leaf inside a parallel group that isn't a Symbol/String, or nesting more than one level deep) raises `Asgard::Error` naming the task and the offending value, instead of failing later with an opaque `NoMethodError`:
|
|
252
260
|
|
|
253
|
-
|
|
261
|
+
```bash
|
|
262
|
+
asgard quality
|
|
263
|
+
# asgard: depends_on proc/block for 'quality' returned invalid stage 123 (Integer); expected a Symbol, String, or Array of them
|
|
264
|
+
```
|
|
254
265
|
|
|
255
|
-
**When it runs:** once, when `validate_deps!` runs (right after the `.loki` chain finishes loading, before any task dispatches). The resolved result replaces the Proc in the dependency table, so cycle detection, undefined-task checks, and arity checks all run against the *resolved* list — a Proc that references an undefined task, or that itself introduces a cycle, is caught at startup exactly like a plain array would be:
|
|
266
|
+
**When it runs:** once, when `validate_deps!` runs (right after the `.loki` chain finishes loading, before any task dispatches). The resolved result replaces the Proc/block in the dependency table, so cycle detection, undefined-task checks, and arity checks all run against the *resolved* list — a Proc/block that references an undefined task, or that itself introduces a cycle, is caught at startup exactly like a plain array would be:
|
|
256
267
|
|
|
257
268
|
```bash
|
|
258
269
|
asgard quality
|
|
259
270
|
# asgard: undefined task(s) in depends_on: ghost_check
|
|
260
271
|
```
|
|
261
272
|
|
|
262
|
-
**`self` inside the Proc:** since
|
|
273
|
+
**`self` inside the Proc/block:** since it's written directly in a `class Tasks` body, it lexically captures that class as `self` — so it can call `all_commands`, `_deps`, or any other class-level method bare, without a `self.class.` prefix, even though it's actually invoked later from inside `validate_deps!`.
|
|
263
274
|
|
|
264
|
-
**If the Proc raises**, the error is caught and re-raised as `Asgard::Error` naming the task it was declared for:
|
|
275
|
+
**If the Proc/block raises**, the error is caught and re-raised as `Asgard::Error` naming the task it was declared for:
|
|
265
276
|
|
|
266
277
|
```
|
|
267
278
|
asgard: depends_on proc for 'quality' raised RuntimeError: boom
|
data/docs/index.md
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
<li><strong>Dotenv Support</strong> — load <code>.env</code> files into the environment with <code>dotenv</code></li>
|
|
19
19
|
<li><strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories</li>
|
|
20
20
|
<li><strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files loaded via <code>import</code></li>
|
|
21
|
+
<li><strong>Scheduled Tasks</strong> — run any task periodically under launchd (macOS) or systemd timers (Linux) with <code>schedule</code> and <code>asgard schedule install</code></li>
|
|
21
22
|
<li><strong>Built-in Flags</strong> — <code>--version</code>, <code>--debug</code>, <code>--verbose</code>, and <code>--doctor</code> available on every task</li>
|
|
22
23
|
</ul>
|
|
23
24
|
</td>
|
data/docs/schedule.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Scheduled Tasks
|
|
2
|
+
|
|
3
|
+
Asgard can run any task on a schedule. You declare schedules in the project's own `.loki`, and `asgard schedule install` hands them to the platform's own scheduler: **launchd** on macOS, **systemd user timers** on Linux. Both schedulers run a calendar job that was missed while the machine slept as soon as it wakes.
|
|
4
|
+
|
|
5
|
+
Scheduling is built into the gem, so there is nothing to import or require.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Declaring Schedules
|
|
10
|
+
|
|
11
|
+
Call `schedule` at class level inside `Tasks`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# .loki
|
|
15
|
+
class Tasks
|
|
16
|
+
desc "daily_summary", "Summarize the day's work"
|
|
17
|
+
def daily_summary = sh "bin/summary"
|
|
18
|
+
|
|
19
|
+
schedule :daily_summary, at: "17:30", on: :weekdays
|
|
20
|
+
schedule :weekly_report, at: "16:00", on: :friday
|
|
21
|
+
schedule :sync, every: 3600 # seconds
|
|
22
|
+
schedule :backup, at: %w[02:00 14:00] # on: defaults to :daily
|
|
23
|
+
|
|
24
|
+
# The task's own options go in options:, split the way a shell would
|
|
25
|
+
# (quotes respected).
|
|
26
|
+
schedule :report, options: "--format md -v", at: "08:00", on: :weekdays
|
|
27
|
+
schedule :report, options: "--period week", at: "16:00", on: :friday
|
|
28
|
+
schedule :notify, options: "--msg 'backup done'", every: 86_400, as: "notify_daily"
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A declaration needs exactly one of `at:` or `every:`.
|
|
33
|
+
|
|
34
|
+
| Keyword | Meaning |
|
|
35
|
+
|---------|---------|
|
|
36
|
+
| `at:` | `"HH:MM"` (24-hour), or an Array of times |
|
|
37
|
+
| `on:` | `:daily` (default), `:weekdays`, `:weekends`, a day (`:friday`), or an Array of days |
|
|
38
|
+
| `every:` | Interval in seconds, or anything that responds to `in_seconds` (an ActiveSupport `Duration`) |
|
|
39
|
+
| `options:` | The task's own arguments, as a String (split shell-style) or an Array of words |
|
|
40
|
+
| `env:` | `{ "KEY" => "value" }`: literal environment variables added to the job |
|
|
41
|
+
| `as:` | The entry's name (letters, digits, `_ . -`) |
|
|
42
|
+
|
|
43
|
+
### Entry Names
|
|
44
|
+
|
|
45
|
+
Every entry has a name, and the subcommands below take it as `NAME`. The name defaults to the task name. When the entry has `options:`, the default is a slug of the task plus its options (`:report, options: "--format md -v"` becomes `report-format-md-v`), so one task can be scheduled several times with different flags. `as:` overrides the default. Declaring two *different* entries with the same name raises an error.
|
|
46
|
+
|
|
47
|
+
### Durations
|
|
48
|
+
|
|
49
|
+
Asgard doesn't depend on ActiveSupport. If you want `every: 3.minutes`, require it yourself at the top of your `.loki`:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
require "active_support/core_ext/integer/time"
|
|
53
|
+
|
|
54
|
+
class Tasks
|
|
55
|
+
schedule :sync, every: 15.minutes
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Managing Schedules
|
|
62
|
+
|
|
63
|
+
`schedule` is also a command with subcommands. They work the same way on both platforms:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
asgard schedule preview # print the job files install would write
|
|
67
|
+
asgard schedule install # load declared entries; drop entries no longer declared
|
|
68
|
+
asgard schedule list # installed entries, schedule, state, last exit status
|
|
69
|
+
asgard schedule stop NAME # stop one entry (stays stopped across reboots and installs)
|
|
70
|
+
asgard schedule start NAME # start a stopped entry, or install just this one
|
|
71
|
+
asgard schedule trigger NAME # run an installed entry now, under the scheduler
|
|
72
|
+
asgard schedule log NAME [-f] # print the entry's log (-f keeps following it)
|
|
73
|
+
asgard schedule remove # unload and delete all of this project's entries
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Re-run `asgard schedule install` after changing declarations or your `PATH`.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## How Jobs Run
|
|
81
|
+
|
|
82
|
+
Each entry runs `asgard <task> [options]` from the directory that holds `.loki`, so installing from any subdirectory gives the same result. The job gets the `PATH` that was current when you ran `install`, plus any `env:` variables.
|
|
83
|
+
|
|
84
|
+
If the project has a `.envrc`, the job runs under `direnv exec`, so API keys and other secrets load from `.envrc` at run time and are never copied into the job files. If `direnv` isn't on your `PATH`, `install` warns that `.envrc` will not be loaded.
|
|
85
|
+
|
|
86
|
+
Entries are scoped to the project (the name of the directory holding `.loki`), so `list`, `install` and `remove` only touch this project's jobs.
|
|
87
|
+
|
|
88
|
+
| | macOS (launchd) | Linux (systemd) |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| Job files | `~/Library/LaunchAgents/com.madbomber.asgard.<project>.<name>.plist` | `~/.config/systemd/user/asgard.<project>.<name>.{service,timer}` |
|
|
91
|
+
| Logs | `~/Library/Logs/asgard/` | `~/.local/state/asgard/` (honors `XDG_STATE_HOME`) |
|
|
92
|
+
| Stop | `launchctl disable` | `systemctl --user disable --now` |
|
|
93
|
+
| Caveats | runs only while you're logged in | runs only while you're logged in unless `loginctl enable-linger`; needs systemd 240+ |
|
|
94
|
+
|
|
95
|
+
Other platforms aren't supported. The `schedule` subcommands there exit with an error.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## Name Collisions
|
|
100
|
+
|
|
101
|
+
The built-in command is registered as `_schedule` and mapped to `schedule`, following Asgard's `_` convention for gem-owned tasks. If your `.loki` defines its own `schedule` task, `asgard schedule` still dispatches to the built-in command.
|
data/docs/shell.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Asgard provides two methods for running shell commands and scripts from within task bodies: `sh` for shell commands and heredocs, and `shebang` for polyglot scripts. Both are provided by `Asgard::Shell` and mixed into every `Tasks` instance.
|
|
4
4
|
|
|
5
|
-
Both methods exit with the command's status code on failure — they do not raise Ruby exceptions.
|
|
5
|
+
Both methods exit with the command's status code on failure — they do not raise Ruby exceptions. `sh` also accepts `exec: true` to replace the asgard process outright instead of forking — see [Handing Off with `exec`](#handing-off-with-exec).
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -80,6 +80,35 @@ class Tasks
|
|
|
80
80
|
end
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
### Handing Off with `exec`
|
|
84
|
+
|
|
85
|
+
Pass `exec: true` to hand the command the asgard process itself instead of forking a child. Under the hood this calls `Kernel.exec` rather than `system`, which replaces the running ruby process image with the command — asgard doesn't stick around waiting on it.
|
|
86
|
+
|
|
87
|
+
Use this for a task's final, long-running command — a dev server, a REPL, anything meant to take over the terminal — so there's no idle ruby process sitting in memory alongside it, and Ctrl-C is handled directly by the command instead of unwinding back through asgard:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class Tasks
|
|
91
|
+
desc "Documentation server startup"
|
|
92
|
+
depends_on :doc_builder
|
|
93
|
+
def doc_server = sh "mkdocs serve", exec: true
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Since the process is replaced, nothing after the `sh` call ever runs, and `depends_on` chains upstream of it must already have finished (they have — dependencies run before the task body).
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
class Tasks
|
|
101
|
+
desc "Start a REPL — never returns to asgard"
|
|
102
|
+
def console
|
|
103
|
+
sh "bundle exec pry", exec: true
|
|
104
|
+
puts "unreachable"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
!!! note
|
|
110
|
+
`exec: true` only makes sense for a command meant to run for the lifetime of the process. Don't use it for a step with more work queued after it in the same task.
|
|
111
|
+
|
|
83
112
|
---
|
|
84
113
|
|
|
85
114
|
## `shebang` — Polyglot Scripts
|
data/docs/tasks.md
CHANGED
|
@@ -206,6 +206,46 @@ Without `default_task`, running `asgard` with no arguments displays the help mes
|
|
|
206
206
|
|
|
207
207
|
---
|
|
208
208
|
|
|
209
|
+
## Abbreviated Command Matching
|
|
210
|
+
|
|
211
|
+
Every task is a Thor command, and Thor resolves any unambiguous prefix of a command name to that command automatically — no Asgard code involved, and nothing to declare. Given:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
class Tasks
|
|
215
|
+
desc "Check code style with RuboCop"
|
|
216
|
+
def rubocop_check = sh "bundle exec rubocop"
|
|
217
|
+
|
|
218
|
+
desc "Run the test suite"
|
|
219
|
+
def test_check = sh "bundle exec rake test"
|
|
220
|
+
|
|
221
|
+
desc "Run the test suite with verbose output"
|
|
222
|
+
def test_verbose = sh "bundle exec rake test -v"
|
|
223
|
+
|
|
224
|
+
desc "Deploy to production"
|
|
225
|
+
def deploy = sh "cap production deploy"
|
|
226
|
+
|
|
227
|
+
desc "Deploy to staging"
|
|
228
|
+
def deploy_staging = sh "cap staging deploy"
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
asgard r # same as: asgard rubocop_check — the only task starting with "r"
|
|
234
|
+
asgard test_c # same as: asgard test_check — enough of the name to be unique
|
|
235
|
+
asgard test # Ambiguous command test matches [test_check, test_verbose]
|
|
236
|
+
asgard deploy # runs deploy, not deploy_staging — see below
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Thor matches on a plain prefix (`command_name.start_with?(typed_string)`), so the shortest string that is still unique for your task set works. Two details worth knowing:
|
|
240
|
+
|
|
241
|
+
- **An exact full name always wins**, even if it's also a prefix of another task. `asgard deploy` runs `deploy` itself, never the ambiguous-prefix error, because `deploy` is a defined command — not merely a prefix of `deploy_staging`.
|
|
242
|
+
- **An ambiguous prefix produces a clean error listing every candidate** (`Ambiguous command X matches [...]`) rather than guessing or running the alphabetically-first match. Type enough of the name to disambiguate.
|
|
243
|
+
|
|
244
|
+
!!! tip
|
|
245
|
+
Because matching depends on every task name currently defined, a short prefix that's unique today can become ambiguous tomorrow when a new task with the same stem is added — e.g. adding `rubocop_fix` alongside `rubocop_check` turns `asgard r` from a clean match into `Ambiguous command r matches [rubocop_check, rubocop_fix]`. Use [`map`](#command-aliases) below for a short name you want to guarantee stays stable regardless of what other tasks get added later.
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
209
249
|
## Command Aliases
|
|
210
250
|
|
|
211
251
|
`map` creates short aliases for existing tasks:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# examples/depends_on_block/bad/.loki
|
|
2
|
+
# BAD example: depends_on block returning a mis-shaped result.
|
|
3
|
+
#
|
|
4
|
+
# Compare with ../good/.loki, which does this correctly:
|
|
5
|
+
#
|
|
6
|
+
# depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
|
|
7
|
+
# ^ ^
|
|
8
|
+
# one wrapping [ ] — this is "one stage, containing every matching
|
|
9
|
+
# task, all running in parallel."
|
|
10
|
+
#
|
|
11
|
+
# It's an easy typo to add a SECOND wrapping array, thinking the outer
|
|
12
|
+
# array is "the list of stages" and the inner one is "this stage's tasks"
|
|
13
|
+
# as two separate concerns:
|
|
14
|
+
#
|
|
15
|
+
# depends_on { [[all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)]] }
|
|
16
|
+
# ^ ^
|
|
17
|
+
# that's one stage too many — the middle array ends up
|
|
18
|
+
# *inside* a stage as a dependency, not as the stage itself.
|
|
19
|
+
#
|
|
20
|
+
# depends_on's block/Proc result is validated the moment it resolves
|
|
21
|
+
# (during validate_deps!, before any task runs): every stage must be a
|
|
22
|
+
# Symbol/String (sequential) or an Array of Symbol/String (parallel group)
|
|
23
|
+
# — nesting one level deeper than that is rejected with Asgard::Error
|
|
24
|
+
# instead of failing later with a confusing NoMethodError.
|
|
25
|
+
#
|
|
26
|
+
# Run with (from this directory):
|
|
27
|
+
# asgard quality
|
|
28
|
+
#
|
|
29
|
+
# Expected output (validate_deps! aborts before quality — or any other
|
|
30
|
+
# task — ever runs):
|
|
31
|
+
# asgard: depends_on proc/block for 'quality' returned invalid dependency
|
|
32
|
+
# [:a_check, :b_check, :c_check] (Array) in a parallel group; expected a
|
|
33
|
+
# Symbol or String
|
|
34
|
+
|
|
35
|
+
class Tasks
|
|
36
|
+
desc "Run the first check"
|
|
37
|
+
def a_check = puts "running a_check ..."
|
|
38
|
+
|
|
39
|
+
desc "Run the second check"
|
|
40
|
+
def b_check = puts "running b_check ..."
|
|
41
|
+
|
|
42
|
+
desc "Run the third check"
|
|
43
|
+
def c_check = puts "running c_check ..."
|
|
44
|
+
|
|
45
|
+
# BUG: double-wrapped — [[ ... ]] instead of [ ... ]. See comment above.
|
|
46
|
+
depends_on { [[all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)]] }
|
|
47
|
+
desc "Run every *_check task in parallel, then report"
|
|
48
|
+
def quality
|
|
49
|
+
puts "quality: all *_check tasks passed"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# examples/depends_on_block/good/.loki
|
|
2
|
+
# GOOD example: depends_on with a block instead of a Proc/lambda.
|
|
3
|
+
#
|
|
4
|
+
# A block behaves exactly like `-> { ... }` — it defers resolution to
|
|
5
|
+
# validate_deps! (once, after every .loki file has loaded) instead of
|
|
6
|
+
# resolving the moment depends_on is evaluated. This is the real pattern
|
|
7
|
+
# from this gem's own quality.loki: gather every task whose name ends in
|
|
8
|
+
# `_check`, without having to know their names or load order upfront.
|
|
9
|
+
#
|
|
10
|
+
# The block's return value is validated once it resolves: it must be an
|
|
11
|
+
# Array of stages, each a Symbol/String (sequential) or an Array of
|
|
12
|
+
# Symbol/String (parallel group) — see ../bad/.loki for what happens when
|
|
13
|
+
# that shape is wrong.
|
|
14
|
+
#
|
|
15
|
+
# `{ ... }` braces are used below for the short, single-line blocks (quality,
|
|
16
|
+
# full_cycle) — standard Ruby style reserves `do ... end` for a block that
|
|
17
|
+
# spans multiple statements, shown in smoke further down.
|
|
18
|
+
#
|
|
19
|
+
# Run with (from this directory):
|
|
20
|
+
# asgard quality
|
|
21
|
+
#
|
|
22
|
+
# Expected output:
|
|
23
|
+
# running a_check ...
|
|
24
|
+
# running b_check ...
|
|
25
|
+
# running c_check ...
|
|
26
|
+
# quality: all *_check tasks passed
|
|
27
|
+
|
|
28
|
+
class Tasks
|
|
29
|
+
desc "Run the first check"
|
|
30
|
+
def a_check = puts "running a_check ..."
|
|
31
|
+
|
|
32
|
+
desc "Run the second check"
|
|
33
|
+
def b_check = puts "running b_check ..."
|
|
34
|
+
|
|
35
|
+
desc "Run the third check"
|
|
36
|
+
def c_check = puts "running c_check ..."
|
|
37
|
+
|
|
38
|
+
# self inside the block is the Tasks class (it's written directly in the
|
|
39
|
+
# class body), so all_commands can be called bare, with no self.class.
|
|
40
|
+
# prefix — even though the block itself isn't invoked until later.
|
|
41
|
+
depends_on { [all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)] }
|
|
42
|
+
desc "Run every *_check task in parallel, then report"
|
|
43
|
+
def quality
|
|
44
|
+
puts "quality: all *_check tasks passed"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A block can also return a mix of sequential Symbols and parallel Arrays,
|
|
48
|
+
# exactly like the plain splat form: depends_on :setup, [:a_check, :b_check], :teardown
|
|
49
|
+
desc "Prepare fixtures before the checks run"
|
|
50
|
+
def setup = puts "setup: fixtures ready"
|
|
51
|
+
|
|
52
|
+
desc "Clean up fixtures after the checks run"
|
|
53
|
+
def teardown = puts "teardown: fixtures removed"
|
|
54
|
+
|
|
55
|
+
depends_on { [:setup, %i[a_check b_check], :teardown] }
|
|
56
|
+
desc "setup, then a_check+b_check in parallel, then teardown"
|
|
57
|
+
def full_cycle
|
|
58
|
+
puts "full_cycle: done"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A block spanning multiple statements — local variables, some computation
|
|
62
|
+
# — reads more naturally as do...end than as one long `{ ... }` line.
|
|
63
|
+
# Whatever it does along the way, the last expression is still what gets
|
|
64
|
+
# returned and validated: here, [:setup, Array, :teardown], the same shape
|
|
65
|
+
# as full_cycle above.
|
|
66
|
+
#
|
|
67
|
+
# Gotcha: this block runs during validate_deps!, which happens *before*
|
|
68
|
+
# Thor parses this invocation's CLI options (see the Entry Point Flow in
|
|
69
|
+
# CLAUDE.md) — so checking `--verbose`/`--debug` in here would always see
|
|
70
|
+
# the pre-flag default, never what was actually passed on this run. Keep
|
|
71
|
+
# depends_on blocks limited to shaping the dependency graph itself.
|
|
72
|
+
depends_on do
|
|
73
|
+
all_checks = all_commands.keys.grep(/_check\z/).sort.map(&:to_sym)
|
|
74
|
+
slow_checks = %i[c_check]
|
|
75
|
+
fast_checks = all_checks - slow_checks
|
|
76
|
+
|
|
77
|
+
[:setup, fast_checks, :teardown]
|
|
78
|
+
end
|
|
79
|
+
desc "setup, then every *_check except the slow ones, then teardown"
|
|
80
|
+
def smoke
|
|
81
|
+
puts "smoke: done"
|
|
82
|
+
end
|
|
83
|
+
end
|
data/examples/kitchen_sink.loki
CHANGED
|
@@ -181,6 +181,19 @@ class Tasks
|
|
|
181
181
|
end
|
|
182
182
|
end
|
|
183
183
|
|
|
184
|
+
# ── Asgard: sh — run shell commands, single-line and multi-line heredoc ────
|
|
185
|
+
desc "Show environment info (single-line sh)"
|
|
186
|
+
def env_info = sh "echo Running #{@@app_name} on $(uname -s)"
|
|
187
|
+
|
|
188
|
+
desc "Bootstrap the local environment (multi-line sh / heredoc)"
|
|
189
|
+
def bootstrap
|
|
190
|
+
sh <<~SHELL
|
|
191
|
+
echo "App: #{@@app_name}"
|
|
192
|
+
echo "Ruby: $(ruby -v)"
|
|
193
|
+
echo "Date: $(date)"
|
|
194
|
+
SHELL
|
|
195
|
+
end
|
|
196
|
+
|
|
184
197
|
# ── Thor: no_commands — public helper excluded from CLI and --help ──────────
|
|
185
198
|
no_commands do
|
|
186
199
|
def current_sha
|
|
@@ -17,13 +17,21 @@ module Asgard
|
|
|
17
17
|
# depends_on [:build, :lint] # build and lint in parallel
|
|
18
18
|
# depends_on :setup, [:build, :lint], :test # setup, then build+lint, then test
|
|
19
19
|
#
|
|
20
|
-
# A sole Proc/lambda
|
|
21
|
-
# .loki file has loaded),
|
|
22
|
-
#
|
|
23
|
-
# (sequential) or Array
|
|
20
|
+
# A sole Proc/lambda, or a block in place of the splat args, defers
|
|
21
|
+
# resolution to validate_deps! (after every .loki file has loaded),
|
|
22
|
+
# instead of now. It must return the same shape the splat form above
|
|
23
|
+
# would: an array of stages, each a Symbol (sequential) or Array
|
|
24
|
+
# (parallel group).
|
|
24
25
|
#
|
|
25
26
|
# depends_on -> { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
|
|
26
|
-
|
|
27
|
+
# depends_on { [all_commands.keys.grep(/_check\z/).map(&:to_sym)] }
|
|
28
|
+
def depends_on(*tasks, &block)
|
|
29
|
+
if block
|
|
30
|
+
raise Asgard::Error, "depends_on accepts either task arguments or a block, not both" if tasks.any?
|
|
31
|
+
|
|
32
|
+
tasks = [block]
|
|
33
|
+
end
|
|
34
|
+
|
|
27
35
|
@_pending_deps = tasks
|
|
28
36
|
end
|
|
29
37
|
|
|
@@ -65,7 +73,9 @@ module Asgard
|
|
|
65
73
|
_deps.each do |task, stages_or_proc|
|
|
66
74
|
next unless stages_or_proc.respond_to?(:call)
|
|
67
75
|
|
|
68
|
-
|
|
76
|
+
result = Array(_call_dep_proc(task, stages_or_proc))
|
|
77
|
+
_validate_dep_shape!(task, result)
|
|
78
|
+
_deps[task] = _stages_from(result)
|
|
69
79
|
end
|
|
70
80
|
end
|
|
71
81
|
|
|
@@ -75,6 +85,32 @@ module Asgard
|
|
|
75
85
|
raise Asgard::Error, "depends_on proc for '#{task}' raised #{e.class}: #{e.message}"
|
|
76
86
|
end
|
|
77
87
|
|
|
88
|
+
# A resolved proc/lambda/block result must be an Array of stages, each
|
|
89
|
+
# either a Symbol/String (sequential) or an Array of Symbol/String
|
|
90
|
+
# (parallel group) — no deeper nesting, no other leaf types.
|
|
91
|
+
def _validate_dep_shape!(task, result)
|
|
92
|
+
result.each { |stage| _validate_dep_stage!(task, stage) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def _validate_dep_stage!(task, stage)
|
|
96
|
+
case stage
|
|
97
|
+
when Symbol, String then nil
|
|
98
|
+
when Array then stage.each { |leaf| _validate_dep_leaf!(task, leaf) }
|
|
99
|
+
else
|
|
100
|
+
raise Asgard::Error,
|
|
101
|
+
"depends_on proc/block for '#{task}' returned invalid stage #{stage.inspect} " \
|
|
102
|
+
"(#{stage.class}); expected a Symbol, String, or Array of them"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def _validate_dep_leaf!(task, leaf)
|
|
107
|
+
return if leaf.is_a?(Symbol) || leaf.is_a?(String)
|
|
108
|
+
|
|
109
|
+
raise Asgard::Error,
|
|
110
|
+
"depends_on proc/block for '#{task}' returned invalid dependency #{leaf.inspect} " \
|
|
111
|
+
"(#{leaf.class}) in a parallel group; expected a Symbol or String"
|
|
112
|
+
end
|
|
113
|
+
|
|
78
114
|
def _check_orphaned_deps!
|
|
79
115
|
pending = Array(@_pending_deps)
|
|
80
116
|
return unless pending.any?
|
|
@@ -107,7 +143,7 @@ module Asgard
|
|
|
107
143
|
# the order itself isn't otherwise used (execution order comes from the
|
|
108
144
|
# stage groups each task's own depends_on declared).
|
|
109
145
|
def _build_and_sort_graph(all_task_names)
|
|
110
|
-
full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task
|
|
146
|
+
full_graph = all_task_names.to_h { |task| [task, _deps.fetch(task) { [] }.flatten] }
|
|
111
147
|
Graph.new(full_graph).tsort
|
|
112
148
|
end
|
|
113
149
|
|
data/lib/asgard/base/task_dsl.rb
CHANGED
|
@@ -48,11 +48,10 @@ module Asgard
|
|
|
48
48
|
here = caller_locations(1, 1).first if active
|
|
49
49
|
|
|
50
50
|
if active && @_default_task_location
|
|
51
|
-
# rubocop:disable Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
|
|
51
|
+
# rubocop:disable-next Style/StderrPuts -- warn bypasses $stderr in Ruby 4.0, breaking capture_io in tests
|
|
52
52
|
$stderr.puts "asgard: default_task :#{meth} at #{here.path}:#{here.lineno} " \
|
|
53
53
|
"overrides default_task :#{@_default_task_name} set at " \
|
|
54
54
|
"#{@_default_task_location.path}:#{@_default_task_location.lineno}"
|
|
55
|
-
# rubocop:enable Style/StderrPuts
|
|
56
55
|
end
|
|
57
56
|
if active
|
|
58
57
|
@_default_task_location = here
|