Runna.Fs
0.2.0
dotnet add package Runna.Fs --version 0.2.0
NuGet\Install-Package Runna.Fs -Version 0.2.0
<PackageReference Include="Runna.Fs" Version="0.2.0" />
<PackageVersion Include="Runna.Fs" Version="0.2.0" />
<PackageReference Include="Runna.Fs" />
paket add Runna.Fs --version 0.2.0
#r "nuget: Runna.Fs, 0.2.0"
#:package Runna.Fs@0.2.0
#addin nuget:?package=Runna.Fs&version=0.2.0
#tool nuget:?package=Runna.Fs&version=0.2.0
Runna.Fs

Typed, namespaced, exhaustive F# build targets. Your targets are DU cases, and a case you forget to handle in Bind is a compiler warning (FS0025). Target blocks are Proc.Fs shell { } blocks, so commands are sh $"…" lines.
Install
dotnet add package Runna.Fs
Requires .NET 11. The package pulls in Runna (the underlying C# engine) and Proc.Fs transitively.
Quick start
open Nullean.Runna.Fs
open Proc.Fs
type TestOptions = { Filter: string option }
type PkgTarget =
/// Pack the NuGet packages
| Generate
/// Validate the packed NuGet packages
| Validate
type Target =
/// Clean known output locations
| Clean
/// Run build
| Build
/// Run build then tests
| Test of TestOptions
| Pkg of PkgTarget
/// Test, then pack and validate
| Release
[<EntryPoint>]
let main argv =
let app = RunnaApp<Target>("build", None)
let config = app.Option<string>("--config", desc = "Build configuration", defaultValue = "Release")
app.Bind <| function
| Clean -> target [] { sh $"dotnet clean" }
| Build -> target [Clean] { sh $"dotnet build -c {config.Value}" }
| Test o -> target [Build] {
match o.Filter with
| Some f -> sh $"dotnet test -c {config.Value} --filter:{f}"
| None -> sh $"dotnet test -c {config.Value}" }
| Pkg Generate -> target [] { sh $"dotnet pack -c Release -o build/output" }
| Pkg Validate -> target [] { sh $"dotnet nupkg-validator build/output" }
| Release -> command {
requires [Test { Filter = None }]
composes [Pkg Generate; Pkg Validate] }
app.RunAsync(argv).GetAwaiter().GetResult()
build --help
build test --filter Unit --config Debug
build pkg --help
build release
build release -s # skip the tests, run only pkg generate, pkg validate
Commands: (pipeline entry points — compose and sequence targets)
release Test, then pack and validate
Targets: (atomic steps — can also be run directly)
clean Clean known output locations
build Run build (depends on: clean)
test Run build then tests (depends on: build)
Namespaces:
pkg
The package depends on Proc.Fs 0.17, so open Proc.Fs brings in sh, the captures and exec { } without another reference.
Targets: target [deps] { ... }
target [Clean] { ... } declares a target that runs after Clean; -s / --single-target skips the dependencies. The block is a Proc.Fs shell { } block that runs only when the target does:
| Info -> target [] {
let! sha = text $"git rev-parse --short HEAD" // standard out as one trimmed string
let! files = lines $"git ls-files" // standard out as a list of lines
for file in files |> List.filter (fun f -> f.EndsWith ".fs") do
sh $"dotnet fantomas {file}" // {file} is always one argument
printfn $"formatted commit {sha}" }
Inside target [] { } |
Does |
|---|---|
sh $"…" |
runs the command; a failure fails the target |
let! s = text $"…" |
standard out as one trimmed string |
let! ls = lines $"…" |
standard out as a list of lines |
let! code = exitCode $"…" |
the exit code, without failing |
let! ok = succeeds $"…" |
whether the exit code is 0, without failing |
cd, pushd, popd, env, setEnvs, unsetEnv |
change the commands that follow |
use! _ = inDir "src", use! _ = withEnv [...] |
change them until the end of the enclosing scope |
Plain F# works too: if, match, loops, printfn, and exec { } from earlier versions. sh splits only the text you wrote, and each {value} stays one argument whatever it contains, so no value can start another command. See the Proc.Fs README for quoting, pipes (|) and multi-line commands.
The first command that fails fails the target, and RunAsync returns 1.
Commands: command { ... }
| Release -> command {
requires [PristineCheck; Test { Filter = None }] // gates, skipped under -s
composes [Pkg Generate; Pkg Validate] } // the work, always runs
concurrently [...] adds a stage whose targets run side by side (F# reserves parallel). Stages run in the order
they are written:
| Release -> command {
requires [Test { Filter = None }]
concurrently [Pkg Generate; Docs Build]
composes [Publish] }
-j / --jobs caps how many targets run at once, and -k / --keep-going lets the targets that do not depend on a
failure finish. Output of targets running side by side, sh commands included, carries their names, and a failure
stops the sh commands still running.
See Running targets side by side.
Pipe a command into executes { } for a body that runs after every composed target. The block is the same as a
target's: sh, let! captures and plain F#, and nothing in it runs until the command does:
| Publish -> command {
requires [Release]
composes [CreateGithubRelease] }
|> executes {
let! sha = text $"git rev-parse --short HEAD"
printfn $"published {sha}" }
Conditions, ordering and cleanup
Pipe a target or command through these, or write them as lines at the top of a target { } block:
| Publish -> target [] {
describe "Push the packages"
needsEnv ["NUGET_API_KEY"]
sh $"dotnet nuget push build/output/*.nupkg" }
A command { } block takes describe "..." the same way. Piped:
| StartDb -> target [] { sh $"docker compose up -d db" } |> cleanup [StopDb] // at the end, even after a failure
| Package -> target [] { sh $"dotnet pack" } |> after [Lint] // after lint when both run
| Publish -> target [] { sh $"dotnet nuget push" }
|> onlyWhen (fun () -> not dryRun.Value) // skip the body; the dependencies still run
|> needsEnv ["NUGET_API_KEY"] // checked before anything starts
|> needsTool ["docker"]
|> describe "Push the packages" // for an .fsx script without an XML doc file
See Conditions, ordering and cleanup.
Descriptions from doc comments
A target's description is the /// doc comment on its DU case. The comment is read from the XML documentation file, so the build project needs <GenerateDocumentationFile>true</GenerateDocumentationFile>. A .fsx script produces no documentation file. In a script, write describe "..." at the top of the block or pipe the target through it, or the target shows its name.
Namespaces via nested DUs
A case whose payload is another DU becomes a CLI namespace: Pkg of PkgTarget gives pkg generate and pkg validate, and pkg --help lists them. Two sub-DUs may share case names, such as Validate; qualify the ambiguous one in Bind (| Schema SchemaTarget.Validate ->).
Per-target typed arguments
A case with a record payload binds the record's fields from CLI flags: Test of TestOptions with { Filter: string option } takes --filter. A dependency can name any value of the case: Test { Filter = Some "Unit" } runs the test target with that value. Two dependencies with equal values run the target once, and with different values once each. Only the target named on the command line binds the command line's flags.
Positional arguments, forwarded arguments and --name=value
Mark a record field [<Argument>] to take a positional token; a 'T list field marked [<Argument>] takes every remaining positional. Mark a 'T list field [<Rest>] to receive everything after a --:
open Nullean.Argh // [<Argument>]; [<Rest>] is in Nullean.Runna.Fs
type TestOptions =
{ [<Argument>] Projects: string list // build test src/A src/B
Suite: string option // --suite unit or --suite=unit
[<Rest>] Rest: string list } // build test -- --treenode-filter "/*/*/MyTests/*"
| Test o -> target [] { sh $"dotnet test {o.Rest}" } // the list becomes separate arguments
A token no field takes exits 2 with Unexpected argument 'x'., and so does any argument for a target without a record.
Hidden steps
step [Deps] { ... } is target left out of help. It still runs when named and when another target depends on it. Runna.step [] (fun ctx -> ...) is the function form.
The default target
app.Default Build names the target that runs when no target is given, so ./build.sh builds. Help shows it. -s without a target still prints help. --version prints the entry assembly's informational version.
Global options
let verbose = app.Flag("--verbose", short = "-v", desc = "Enable verbose output")
let token = app.Option<string option>("--token", desc = "API token", defaultValue = None)
Read them in target blocks through .Value. Help shows each option's type, such as --config <string>, and --token <string?> for an option type. Both --token abc and --token=abc work. A value that does not parse, or a missing value, exits 2 before any target runs. Enum options bind by name, ignoring case.
Choosing what runs
build lint test --filter Unit pack # several targets in order; each takes the arguments up to the next
build release --skip docs # leave a target out, with what only it needed
See Choosing what runs.
Drawing the plan
--graph draws what a run would do and runs nothing. It binds the target's arguments, applies -s and evaluates
conditions, so a skipped target shows as skipped:
build release -s --graph
build release --graph mermaid # or json
A target's --help lists the order its dependencies run in under Runs:. See
Drawing the plan.
mise, make and just
__wrappers sync writes a mise task include, a Makefile include and a justfile import with one task per target, so
mise run test, make test and just test call the build. A project-based build passes the command to run once;
later syncs reuse it:
dotnet run --project build -- __wrappers sync --run "dotnet run --project build --"
The files go to .runna/, and sync prints the line each wrapper's config needs, plus mise hooks that regenerate the
files so they can be gitignored. The Nullean.Runna README
covers the options, the task names (pkg:generate, pkg/generate, pkg-generate) and __wrappers check; the
docs have a page per wrapper.
The explicit functions
target and command are shorthand for these, which also take a description and give the body an FsContext:
Runna.target (deps: 'T list) (desc: string) (body: FsContext -> unit)
Runna.command (requires: 'T list) (composes: 'T list)
Runna.composer (requires: 'T list) (composes: 'T list) (body: FsContext -> unit) // like command { } |> executes { }
failBuild "message" // abort with exit code 1
Learn more
- Documentation: https://runna.nullean.net/
- Source and issues: https://github.com/nullean/runna
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- FSharp.Core (>= 11.0.101)
- Proc.Fs (>= 0.22.1)
- Runna (>= 0.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0 | 0 | 11/13/2026 |