Your build targets are the cases of a C# union or an F# DU. Dependencies run first,
each target takes its own typed flags, and XML docs become --help:
a build script that is a real CLI, with the help of argh.
// what you get
Targets are types, so the compiler checks every dependency, every flag and every name. What runs, and in which order, is something you can ask before anything runs.
Several targets in one go, shared dependencies once. -s runs only the target,
--skip leaves one out with what only it needed.
Draw what a run would do, skipped targets included, as a tree, Mermaid or JSON, and run nothing.
drawing the plan →Parallel stages run at once, with their output prefixed or grouped, and a failure can let the targets that do not depend on it finish.
parallel stages →Run a target only when it applies, order it after another, clean up whatever happened, and check tools and environment variables before the run starts.
modifiers →Generate one task per target for mise, make and just, and let mise hooks keep them up to date.
wrappers →
Every target gets argh's --help: XML doc summaries, typed per-target flags,
and typos answered with the closest target.
// quick start
The same three targets either way: test needs build, which needs clean.
It requires .NET 11.
#!/usr/bin/env -S dotnet -- #:package [email protected] #:property GenerateDocumentationFile=true #:property NoWarn=CS1591 using Nullean.Runna; using static Nullean.Runna.Shell; var app = new RunnaApp<Target>("build"); app.Bind(t => t.Value switch { Clean => app.Target(() => Exec("dotnet clean")), Build => app.Target([new Clean()], () => Exec("dotnet build")), Test => app.Target([new Build()], () => Exec("dotnet test")), _ => throw new InvalidOperationException($"unmapped target {t.Value}") }); return await app.RunAsync(args); /// <summary>Delete build output</summary> record Clean; /// <summary>Build the solution</summary> record Build; /// <summary>Build, then run the tests</summary> record Test; union Target(Clean, Build, Test);
A file-based app is enough: build.cs next to your solution, run with dotnet run build.cs
or straight from the shell.
open Nullean.Runna.Fs open Proc.Fs type Target = /// Delete build output | Clean /// Build the solution | Build /// Build, then run the tests | Test [<EntryPoint>] let main argv = let app = RunnaApp<Target>("build", None) app.Bind <| function | Clean -> target [] { sh $"dotnet clean" } | Build -> target [Clean] { sh $"dotnet build" } | Test -> target [Build] { sh $"dotnet test" } app.RunAsync(argv).GetAwaiter().GetResult()
F# targets live in a small project, say build/build.fsproj with
GenerateDocumentationFile on, run with dotnet run --project build --.
A case you forget in Bind is a compiler warning.
The package READMEs cover the full DSL: Runna for C# and Runna.Fs for F#.
Typed targets, dependencies first, and help you never have to write.