giant-task - the task runner
giant-task is the official task-runner porcelain - a separate
binary that adds named commands (“tasks”) on top of Giant’s build
engine. Dispatched automatically via giant task <name> (Giant
itself doesn’t ship a task subcommand; the dispatcher execs
giant-task on PATH).
$ giant task deploy· building 2 dep(s) ✓ 0 built · 2 cached in 1ms▶ deploy to stagingdeployedThe engine doesn’t know what a task is - giant-task asks the engine
which packages exist, then re-reads each package’s giant.yaml with its
own schema, looking only at the tasks: and services: blocks. If you
don’t install giant-task, those fields in your config are simply
ignored. Other porcelains can claim their own top-level keys the same way.
Install
Section titled “Install”# From crates.io (once published)cargo install giant-task
# From sourcecargo install --path crates/giant-task --git https://github.com/giantdotbuild/giantThe binary just needs to be on PATH. Verify:
$ giant-task --versiongiant-task 0.1.0
$ giant task --version # via the dispatchergiant-task 0.1.0A first task
Section titled “A first task”Add a tasks: block to your giant.yaml:
workspace: name: my-monorepo
targets: - name: "server" inputs: ["cmd/server/**/*.go"] outputs: ["//bin/server"] cwd: "//" command: "go build -o bin/server ./cmd/server"
tasks: serve: command: "./bin/server" description: "Run the server locally" deps: ["//:server"]deps: reference targets by their label - here //:server, the
server target in the root package. In a split repo you’d write the
full path, e.g. deps: ["//crates/giant:giant"].
Then:
$ giant task serve· building 1 dep(s) ✓ 1 built · 0 cached in 240ms▶ Run the server locallylistening on :8080giant-task first asks giant build to materialize the deps, then
runs the task’s command via sh -c in the task’s package directory
(here the root, //).
Tasks in subdirectories
Section titled “Tasks in subdirectories”Tasks are packaged like targets. A tasks: block in any package’s
giant.yaml - not just the root - defines tasks labelled
//<package>:<name>, and each task’s command runs in that package’s
directory by default. So in a monorepo, a subproject keeps its own tasks
next to its code:
my-repo/├── giant.yaml # workspace root (//)├── blackmetal/giant.yaml # tasks //blackmetal:test, //blackmetal:fmt└── cryosleep/giant.yaml # tasks //cryosleep:test, //cryosleep:fmttasks: test: command: "go test ./..." # runs in blackmetal/Both subprojects can define test without colliding - the labels
//blackmetal:test and //cryosleep:test are distinct. giant task list
from anywhere shows the whole tree, grouped by package.
How a bare name resolves
Section titled “How a bare name resolves”You rarely type the full label. A bare giant task test resolves from
your current directory:
- Inside a package, the nearest enclosing package wins. From
blackmetal/,giant task testruns//blackmetal:testeven if other packages also definetest. - Unique across the workspace, it runs wherever it lives. If only
blackmetaldefinesdeploy,giant task deployworks from anywhere. - Shared by several packages with none enclosing your cwd (e.g. at the
repo root with
testin two subprojects), it’s ambiguous and giant-task asks you to qualify it:
$ giant task testgiant-task: task 'test' is defined in several packages; qualify it,e.g. `giant task //blackmetal:test` (candidates: //blackmetal:test, //cryosleep:test)A //pkg:name label always resolves directly, from any directory.
References across packages
Section titled “References across packages”needs:, finally:, and services: entries resolve within the task’s
own package when written bare, or across packages when written as a
label. A //blackmetal:test task with needs: ["lint"] runs
//blackmetal:lint; to reach another package, write the full label
(needs: ["//cryosleep:gen"]).
The task schema
Section titled “The task schema”tasks: <name>: command: "..." # shell command or #! script body; optional # if `services:` is set (foreground supervise) description: "..." # optional; shown in `giant task list` deps: ["..."] # target labels (//pkg:name) to build before running needs: ["..."] # task names (same package) or //pkg:name labels, # run before command services: ["..."] # service names (same package) or labels; start # before, stop after finally: ["..."] # task names or labels to run after command (always) args: # optional; ordered list, bound positionally - name: env default: "..." # present => optional; absent => required choices: ["a", "b"] # constrained set; default must be in choices description: "..." # shown in completion + help - name: rest variadic: true # trailing only; collects the rest into $@ env: # extra env vars KEY: "value" cwd: "..." # package-relative (// escapes to root); # default = the task's package directory timeout_secs: 300 # kill after N seconds; default = no timeout inputs: ["..."] # optional; extra watch globs for files no # target owns (consulted by --watch)Watch mode
Section titled “Watch mode”giant task <name> --watch runs the task once, then re-runs it
whenever a relevant input changes. Ctrl-C exits.
This is dep-aware, and giant-task does no file watching itself. It
opens a giant session and subscribes to the task’s dependencies with
watch.subscribe { targets: deps, globs: inputs }. The engine watches
the inputs of those deps: targets - and their transitive deps - plus
any path matching the task’s inputs: globs, and notifies giant-task
(a watch.changed event) when one changes. So editing a file that a
dependency target consumes retriggers the task, even though the task
never named that file.
$ giant task test:unit --watch· initial run…· watching via engine - Ctrl-C to exit· change detected, re-running…| Flag | Default | Description |
|---|---|---|
--watch | off | Re-run on changes, watched by the engine. |
What gets watched:
deps:- the engine expands these through the graph and watches every input they (transitively) depend on.inputs:- extra globs for files no target owns (e2e sources, fixtures).- Neither declared - falls back to the whole workspace (minus
.git/, the state dir, and the cache dir). Handy as a smoke loop but noisy; declaredeps:/inputs:where you can.
Because the watching lives in core, a task and a giant build --watch
see the same change signal from one shared file-watching implementation.
Task names follow the same rules as workspace names (alphanumeric,
hyphen, underscore; no leading digit), and need only be unique within
their package - two packages can both define test. Any valid name is
allowed; a task named build or test is fine. Tasks are always invoked
as giant task <name>, so they never collide with a giant command like
giant build (which runs the build porcelain); the two namespaces are
separate.
The service schema
Section titled “The service schema”services: <name>: command: "..." # required; shell command (the daemon) description: "..." # optional deps: ["..."] # target labels (//pkg:name) to build before starting needs: ["..."] # other services to bring up (ready) first ready: # optional readiness probe command: "..." # shell snippet; exit 0 = ready period_secs: 1 # poll interval (default 1) timeout_secs: 30 # give up after this (default 30) env: # extra env vars KEY: "value" cwd: "..." # package-relative (// escapes to root); # default = the service's package directoryA service is a long-lived process. When a task brings up services, the
supervisor starts them in dependency order: a service with needs:
waits for each dependency’s ready probe to pass before it starts
(services with satisfied needs start concurrently). The transitive
needs closure is pulled in automatically, so listing api brings up
the db it needs. Cleanup is automatic: when the task exits (any reason
- success, failure, signal), services are sent SIGINT, then SIGTERM if they don’t exit in 2s, then SIGKILL after another 3s.
Dev environments: a task that is its services
Section titled “Dev environments: a task that is its services”A task with services: and no command: supervises those services
in the foreground - the giant dev shape. It brings the stack up
dependency-ordered, streams their prefixed logs, and holds until Ctrl-C
(or until a service exits), then shuts everything down.
services: db: command: "postgres -D ./data" ready: { command: "pg_isready" } api: command: "./bin/api" needs: ["db"] # api starts once db is ready worker: command: "./bin/worker" needs: ["db"]
tasks: dev: services: ["api", "worker"] # no command → foreground supervise$ giant task dev· starting services: db, api, worker[db] listening on 5432[api] serving on :8080[worker] ready^C· interrupted· stopping services: db, api, workerThis is the dev-loop slice of process-compose. It deliberately stops there: no daemon/background mode, no process scaling, no restart policies, no REST control. For those, use process-compose.
Lifecycle of one task
Section titled “Lifecycle of one task”1. build deps → giant build <labels>2. start services → dependency-ordered, each gated on its `ready` probe3. run needs → sequential, declared order4. run command → the task's own command (or supervise, if absent)5. run finally → sequential, declared order; ALWAYS runs6. stop services → SIGINT → SIGTERM → SIGKILLIf a step fails:
| Failure | Effect |
|---|---|
deps build | stop. Nothing else runs. |
services not ready in timeout_secs | stop already-started services, skip needs/command/finally. |
needs task | skip command, still run finally, still stop services. |
command non-zero exit | still run finally, still stop services. The exit code is what the task returns. |
finally task | logged, doesn’t change the task’s exit code. |
Signals
Section titled “Signals”finally and service teardown run on SIGINT or SIGTERM, so a terminal
Ctrl-C, a pkill, or a systemctl stop all trigger them. When a task
has services or a finally, giant-task
installs a handler: a signal - whether from Ctrl-C, pkill, systemctl stop, or a parent supervisor - interrupts the running command (forwarded
to its process group), then the lifecycle falls through to finally and
stops services as usual. The task exits 130 (SIGINT) or 143 (SIGTERM).
Once teardown starts it runs to completion; a second signal won’t cut it
short. A bare command with nothing to clean up keeps the default
behavior - the signal just kills it.
To make whole-subtree teardown work, a command with services or a
finally runs in its own process group. Interactive commands still work:
giant-task hands the terminal to that group while the command runs - the
way a shell does for a foreground job - so a sudo or ssh password
prompt reads the terminal normally, then giant-task takes it back. Off a
real terminal (CI, pipes) this is a no-op.
A worked example with all four hooks:
services: db: command: "docker run --rm -p 5432:5432 postgres:16" ready: command: "pg_isready -h localhost -p 5432"
tasks: run-test: command: "go test ./..." deps: ["//:server"] # build first services: ["db"] # spin up DB needs: ["migrate"] # run schema migrations first finally: ["wipe-test-data"] # always clean up test rows
migrate: command: "./bin/migrator up" services: ["db"] # migrate also needs the DB up
wipe-test-data: command: "psql -c 'TRUNCATE …'" services: ["db"]$ giant task run-test· starting services: db· need: migrate▶ migrate[migrate] applied 4 migrations▶ go test ./...[run-test] ok example/internal/store 0.123s· finally: wipe-test-data▶ wipe-test-data· stopping services: dbArguments
Section titled “Arguments”Tasks support named, validated args and arbitrary forwarding - you
don’t have to choose. Named args make a task discoverable (they show up in
--list, per-task --help, and completions) and validated (choices,
required). Anything you don’t declare can still be forwarded to the command.
args: is an ordered list. Values bind positionally in declaration
order. An arg with no default is required; one with a default is
optional; choices constrains it; a trailing arg may be variadic: true to
collect the rest. Each scalar arg is exported before the command runs as
GIANT_ARG_<NAME> (uppercased) and a plain $name; the variadic arg, and
anything forwarded, becomes the command’s positional parameters ($@). Set
an arg by name with --arg name=value (the scriptable form).
Forwarding args to the command
Section titled “Forwarding args to the command”Whether leftover args reach $@ depends on what the task declares. Three
example tasks cover every case:
tasks: serve: # no args → pass-through wrapper command: 'npx astro dev "$@"'
deploy: # one validated arg, no variadic → strict command: 'kubectl apply -f k8s/$env/ $@' args: - { name: env, choices: ["staging", "prod"] }
test: # named arg + variadic tail command: 'cargo test -p $pkg $@' args: - { name: pkg } - { name: flags, variadic: true }# `serve` declares no args, so it forwards everything (flags included):$ giant task serve --host --port 4321 # → npx astro dev --host --port 4321
# `deploy` is strict: the named arg binds, a stray extra is treated as a typo…$ giant task deploy prod # env=prod$ giant task deploy nowhere # error: not one of [staging, prod]$ giant task deploy prod --force # error: 1 extra ["--force"], use --$ giant task deploy prod -- --force # env=prod, and kubectl also gets --force
# `test` has a variadic tail, so extras land in $@ with no `--` needed:$ giant task test core --nocapture # pkg=core, $@=[--nocapture]$ giant task test core -- --nocapture # same (the `--` is optional here)The rules in one line each:
- No declared
args:→ the task is a pass-through wrapper; all args forward to$@. (This is whydocs-dev/docs-previewneed noargs:.) - Declared args, no variadic → strict; an unexpected extra is rejected as
a likely typo. Add a
variadic: truearg to accept extras on purpose. --→ forwards everything after it to$@verbatim, from any task, the cargo / npm idiom. Use it when a flag you’re forwarding could be mistaken for one of giant-task’s own.
Giant-task’s own flags (--watch, --config, …) come before the task
name (giant task --watch deploy), the same rule git and cargo use.
Per-task help
Section titled “Per-task help”giant task <name> --help prints that task’s signature - its arguments,
which are required, their defaults and choices:
$ giant task deploy --helpdeploy - deploy the app usage: giant task deploy <env> [tag=latest]
env staging|prod target environment tag =latest(giant task --help, with no task name, prints giant-task’s own help.)
Tasks in any language
Section titled “Tasks in any language”If a task’s command begins with a #! shebang line, the whole body is
written to a temp file and exec’d directly, so you can write a task in
any language. Declared args are in the environment; variadic/passthrough
values are the script’s arguments.
tasks: report: args: [{ name: since, default: "HEAD~20" }] command: | #!/usr/bin/env python3 import os, subprocess since = os.environ["GIANT_ARG_SINCE"] print(subprocess.check_output(["git", "log", "--oneline", since]).decode())A body without a shebang runs under sh -c as usual.
Listing tasks
Section titled “Listing tasks”giant task list (or giant-task --list) shows every task in the
workspace, grouped by package:
$ giant task listtasks (my-monorepo)// serve Run the server locally//blackmetal test Run the test suite//cryosleep test Run the test suiteIf you have no tasks declared, this prints a single dim “no tasks defined” note instead.
Output formats
Section titled “Output formats”--format selects how the list is rendered (it works before the task
name or after list):
| Format | Output |
|---|---|
text (default) | Human-readable, grouped by package. |
labels | One //pkg:name per line - pipe into a fuzzy finder. |
json | Structured: workspace name, and each task’s label, package, description, and declared arg schema. |
$ giant task list --format labels//:serve//blackmetal:test//cryosleep:testThe json form carries each task’s args (names, defaults, choices,
variadic), so an external picker can show or fill a task’s arguments. A
fuzzy-finder binding, for example, can list labels and preview each task’s
signature with giant task <label> --help:
giant task "$(giant task list --format labels | fzf \ --preview 'giant task {} --help')"Giant deliberately ships no built-in fuzzy finder or task TUI - the list output is designed to feed the picker you already use.
Dep-phase output
Section titled “Dep-phase output”By default the dep build is collapsed into a one-line summary. A 50-target dependency pull doesn’t fill the terminal before the actual task command runs:
$ giant task serve· building 50 dep(s) ✓ 3 built · 47 cached in 1.24s▶ Run the server locallylistening on :8080On failure, the failing target’s stderr is replayed inline (capped at 50 lines per target):
$ giant task broken· building 1 dep(s)
✗ //:bad going to fail this line too ✗ 1 failed · 0 built · 0 cached in 2msgiant-task: dependency build failed (exit code 1)--verbose (-v) restores the full streamed giant build
output - useful when you want the per-target lines.
Shell completions
Section titled “Shell completions”giant-task has its own completion script generator, separate from
giant’s. Both binaries support bash, zsh, fish, PowerShell,
elvish, and nushell. Pipe the script into your shell’s completion
directory:
# bashgiant-task --completions bash > ~/.local/share/bash-completion/completions/giant-task
# zshgiant-task --completions zsh > "${fpath[1]}/_giant-task"
# fishgiant-task --completions fish > ~/.config/fish/completions/giant-task.fish
# nushellgiant-task --completions nushell >> ~/.config/nushell/completions.nuDynamic completion of task names works at TAB time - giant-task
scans the workspace and returns the matching tasks (bare names plus their
//pkg:name labels), including their descriptions. Same idea for giant
itself: target labels from the merged giant.yaml package files.
How it composes with the engine
Section titled “How it composes with the engine”The giant-task binary doesn’t reach into Giant’s internals. The
contract:
- Build deps: spawned as
giant build <labels…> --events ndjson, the output parsed event-by-event so the porcelain can render a compact summary instead of streaming everything. - Package discovery:
giant-taskcalls the engine’s workspace scan to learn which packages exist and where their config files are - the same scan core emits aspackage.describedevents over the protocol, including directories that define only tasks. - Config schema: each package’s
giant.yamlis parsed bygiant-taskdirectly with its own narrowTopLevel { tasks, services }shape. Core’s wider config parsing isn’t involved.
If you want to write your own porcelain (giant-deploy,
giant-bench, anything), see Porcelains
for the dispatch mechanism and the NDJSON event
protocol for the wire format.
Environment variables
Section titled “Environment variables”| Variable | Meaning |
|---|---|
GIANT_ARG_<NAME> | Set per declared scalar arg (uppercased name). |
$<name> | Plain-name binding for the same arg (lowercase as declared). |
GIANT_TASK_BUILD_BIN | Override the giant binary used for giant build subprocess calls. Useful in tests; rarely needed otherwise. |
NO_COLOR | Disable ANSI colors in giant-task’s own output (giant build’s output is also subject to this). |
What giant-task DOESN’T do
Section titled “What giant-task DOESN’T do”A short list, deliberately:
- No service restart policies. If a service dies during your task,
the task fails. (The right default for tests; if you want auto-restart
during a dev loop, run
process-composedirectly.) - No service-to-service dependency ordering. Express order by wrapping in a task that declares both services and the order it needs. Or use process-compose for nested service graphs.
- No
afterDAG / log ring buffers / parallelneeds.needs:is sequential,finally:is sequential, and that’s it. - No watch-mode service auto-restart on file changes. Use
watchexecorprocess-compose. - No nested tasks (
giant db migrate). Use flat names (giant task db-migrate) or namespace via prefix. - No
outputs:/ caching on tasks. Tasks run every time. If you want caching, declare a build target withoutputs:and anexists:check;giant buildhandles it cleanly. - No HTTP/TCP probe shortcuts.
ready.command:covers everything (curl -fs http://...,nc -z host port). HTTP/TCP sugar may arrive if heavy users ask.
Service supervision uses the tokio-process-tools crate for the cross-platform tricky parts (signal escalation, broadcast output streams). The policy on top - schema, lifecycle, readiness loop - is giant-task’s own.