# Partas.Build # Overview > The building blocks of a Partas.Build command. Partas.Build turns F# workflow definitions into command-line tools. A stage declares the inputs it reads; each command derives its options, validation, and help from those stages. ## Start with a command [Install the packages](installation.md), then [create your first build](getting-started.md). ```fsharp open Partas.Build let root = Command.root { command "build" { stage "compile" { run "dotnet build" } } } let build args = Command.invoke args root ``` Construction runs nothing. `Command.invoke [ "build" ] root` parses the arguments and runs the selected workflow. ## The building blocks - **Step**: one process or F# function inside a stage. - **Stage**: named steps, conditions, and execution settings. - **Input**: an option or argument bound where the workflow needs it. - **Pipeline**: an ordered workflow of stages. - **Command**: a CLI entry point containing stages or pipelines. - **Root**: the command tree, invoked once or reused by a host. ## Choose your next task - [Inputs and help](inputs.md): bind options with `let!` and `and!`. - [Processes and secrets](steps.md): preserve argument boundaries and mask sensitive values. - [Composition](composition.md): reuse stages and commands. - [Execution](execution.md): conditions, parallelism, timeouts, and failure handling. - [Baked stages](baked.md): common .NET build operations. - [Agents and JSON](agents.md): inspect and run a build with structured output. [Capabilities](CAPABILITIES.md) lists the operations. [Workflow reference](workflow-reference.md) retains the detailed examples, and the [API reference](https://shayanhabibi.github.io/Partas.Build/reference/) supplies full signatures. --- # Extensions Extensions package workflows for specific tools as composable Partas.Build operations, inputs, stages and commands. - [EasyBuild.ShipIt](shipit.md): conventional-commit versioning, synchronized changelog/project updates, release commands and explicit setup. Use `Partas.Build.Baked` for general build operations. Use an extension when its tool should own a workflow, such as calculating package versions from commit history. --- # External Annotations ReSharper and Rider read code annotations — `[]`, `[]`, `[]` and the rest — from two places: an assembly's own metadata, and an **external annotations** sidecar named `.ExternalAnnotations.xml` next to the assembly. The sidecar is honoured regardless of the assembly's metadata. `Partas.ExternalAnnotations` generates that sidecar from the attributes already in your assembly and ships it inside `lib//` of your NuGet package, so the annotations survive a binary reference. ## Why it exists `Partas.Solid` carries ~596 `[]` attributes on F# optional type extensions, and **none of them reached consumers**: ReSharper's code-annotation support is C#-first, and its handling of F# extension members — which compile to mangled static methods — is idiosyncratic. A sidecar sidesteps both. A Rider 2026.2 harness established what actually injects across a binary reference: | Site | Injects | |---|---| | ordinary static method, parameter-level | yes | | mangled F# extension setter, **member**-level | **yes** | | mangled F# extension setter, parameter-level | no | The generator therefore emits at member level wherever the attribute sits on a member. That makes all 596 sites resolve. ## The pieces | Project | What it is | |---|---| | `Partas.ExternalAnnotations` | The generator. Reflection-only scan of an assembly, XML doc-id emission. No dependency on Partas.Build. | | `Partas.Build.ExternalAnnotations` | Partas.Build stages and commands over the generator, plus the MSBuild `.targets` as an embedded resource and a packed `build/` asset. | | `Partas.ExternalAnnotations.Tool` | The `partas-annotations` dotnet tool: a `rootCommand` over the library's three commands. | The tool is what MSBuild shells out to during pack. See [the F# surface](external-annotations-api.md) to own the behaviour in your own build instead. Your assembly is never loaded for execution and its target framework is irrelevant. Any tool host produces byte-identical output from any assembly. ## Quick start ```shell dotnet tool install --local Partas.ExternalAnnotations.Tool # writes Directory.Build.targets, pinning the generator command inside it dotnet partas-annotations init --annotations-tool "dotnet partas-annotations" git add Directory.Build.targets # commit it; see below dotnet pack -c Release dotnet partas-annotations verify --package bin/Release/My.Lib.1.0.0.nupkg --min-members 1 ``` That is the whole loop. After `init`, every pack is correct — `dotnet pack`, CI and Rider's Pack button alike — because the logic lives in the repository, not in a build script. ### The three commands ```shell partas-annotations generate --assembly PATH --output PATH [--strict] partas-annotations verify --package PATH [--min-members N] partas-annotations init [--directory PATH] [--annotations-tool COMMAND] [--force] ``` - `--strict` promotes skipped members from a warning to a failure. A skip means those annotations are silently absent from the output, so use it for a library whose count you know. - `--min-members N` fails a sidecar that exists but annotates fewer members than that. Absence is always a failure; an *empty* sidecar is only one when you say what you expect. - `init` refuses to clobber an existing `Directory.Build.targets` unless given `--force`. ## What the targets file does `Directory.Build.targets` hooks `TargetsForTfmSpecificContentInPackage`, which runs **once per inner build**. `$(TargetFramework)` is the real TFM even when multi-targeting, so each TFM's `lib/` folder gets annotations generated from *its own* assembly. A plain `` does not work: `None` items are evaluated in the outer build, where `$(TargetFramework)` is empty. Per inner build, in order: 1. `PartasGenerateExternalAnnotations` — if a generator command is configured, delete the previous output and `Exec` the tool against `$(TargetPath)`, capturing its exit code. 2. `PartasPackExternalAnnotations` — pack the generated file if generation exited `0`, else the committed fallback file if one exists, else emit a warning naming the path it looked for. Properties: | Property | Meaning | |---|---| | `PartasExternalAnnotationsTool` | Command that generates the file, e.g. `dotnet partas-annotations`. Empty means generate nothing and pack whatever file already exists. | | `PartasExternalAnnotationsFile` | Path of the annotations file. Defaults to a per-TFM path under `obj\` when generating, and to `ExternalAnnotations\$(AssemblyName).ExternalAnnotations.xml` when packing a pre-existing file. | Two injection routes, and both may be active at once without duplicate-import warnings: 1. A committed `Directory.Build.targets` — the normal one. 2. `dotnet pack -p:CustomAfterMicrosoftCommonTargets=` — for projects you cannot commit into. `ExternalAnnotations.packArgs` builds that argument. `Exec` runs with `ContinueOnError="WarnAndContinue"`. A missing or unrestored tool degrades to packing the committed file instead of failing your build. ## Behaviour to know - **`init` is a developer command, not a CI stage.** The file it writes is ordinary build configuration, meant to be reviewed and committed. - **Generation happens at pack time**, from the assembly being packed, in the same invocation. That makes it correct for Rider's Pack button too, not only for packs that went through a build stage. - **A committed file is a valid fallback.** With no tool available, commit `ExternalAnnotations/.ExternalAnnotations.xml` and packs stay correct, with no warnings. - **A failed generation never ships the last good run's file.** The targets delete their own previous output first and use the generated file only on exit code `0`. - **Skips warn and continue.** Pass `--strict` to fail instead, in a pipeline with a known-good count. ## Known limits - Parameter-level annotations on mangled F# extension members did not inject in the Rider 2026.2 harness; put them on the member instead. They are still **generated**: the sidecar is correct, the limitation is on the consuming side, and an annotation already in the file starts working once that is fixed. - Generic parameters are annotated too, on both types and methods (``). A nested type redeclares its enclosing type's generic parameters as its own, attributes included, so those are skipped: `Outer.Inner` gets `U` only, matching the source. - Several attributes on one parameter share a single `` element rather than getting one each, matching ~99.5% of ReSharper's own shipped annotation files. Both forms occur in those files, so both are accepted. - Annotations on property getters are inert in ReSharper; they are still emitted. - The generator collects the whole `JetBrains.Annotations` namespace. Narrow it with `--attribute` or `AttributeFilter`. - The attributes from the `JetBrains.Annotations` NuGet package are `[]`, so unless the annotated project defines that constant they are stripped from its metadata and there is nothing for the generator to find. Assemblies declaring their own copies of the attributes are unaffected. - Creating a `Directory.Build.targets` shadows any parent one. The emitted file chain-imports the parent to compensate, but if you already have one, merge by hand rather than passing `--force`. --- # Partas.Build > Stage-owned inputs, generated CLIs, typed dependencies, and inspectable builds for people and agents.
F# build workflows · For people and agents

A build you can
read and reuse.

Declare the inputs and dependencies each stage needs. Compose a CLI with deduplicated options and generated help. Inspect the plan, then run it with typed results or JSON.

Build.fs
let build = input {
    let! config = Input.option<string> "--configuration"
                  |> Input.def "Release"
    return stage "compile" {
        run (cmd $"dotnet build -c {config}")
    }
}

let root = Command.root {
    command "build" { build }
}
$ build build --help
Options: --configuration <value> [default: Release]
## Inputs belong to the stage that needs them Declare an option once, then bind it in every stage that uses it. Reusing the same input declaration deduplicates it across the command: two compile stages, one `--configuration` option. The command collects inputs from its stages, including nested ones. Options, defaults, validation, and help follow the workflow you compose. [Declare and bind inputs →](Build/inputs.md)
```fsharp open Partas.Build let configuration = Input.option "--configuration" |> Input.def "Release" let compile project = input { let! config = configuration return stage $"compile {project}" { run (cmd $"dotnet build {project} -c {config}") } } let root = Command.root { command "build" { compile "src/Core/Core.fsproj" compile "src/App/App.fsproj" } } ```

Two stages share one input declaration. build --help lists --configuration once.

## Compose inputs. Generate the CLI before execution. Bind independent inputs with `let!` and `and!`. This **applicative composition** keeps the complete input set available before parsing their values. Static analysis of those declarations builds the command's options and `--help` without running stages. Add a reusable block and its inputs come with it; remove the block and its unused options disappear. [Compose reusable workflows →](Build/composition.md)
```fsharp let quick = Input.option "--quick" let build = input { let! config = configuration and! skipRestore = quick return pipeline "build" { stage "restore" { when' (not skipRestore) "--quick is set" run "dotnet restore" } stage "compile" { run (cmd $"dotnet build -c {config}") } } } ```

A command containing build gets both --configuration and --quick. No separate option registration.

## Readable for people. Inspectable by agents. People get generated help, stage names, skip reasons, and failure reports. Agents get a command schema, a static execution plan, and structured run results from the same definition. `--schema` describes the CLI. `--explain --json` describes the workflow without running steps or effectful conditions. `--json` emits a final result document with outcomes, failures, and timings; detected AI environments enable it by default. [Inspect a build and consume JSON →](Build/agents.md)
```shell # What inputs does this command accept? dotnet run --project Build.fsproj -- build --help # Give an agent the command's schema. dotnet run --project Build.fsproj -- build --schema # Inspect the static plan without executing it. dotnet run --project Build.fsproj -- build --explain --json # Execute, then read the final JSON result. dotnet run --project Build.fsproj -- build --json ```

Inspect first, execute when ready. In a host, Command.invoke returns a typed RunResult directly.

## Model work as operations and dependencies An `Operation<'T>` is deferred work with a typed result. Compose process execution with `Operation.map`, then give the work a named producer. Consumers declare the producer they need. A shared producer runs once per invocation, before its consumers, and both receive the same result. Dependency validation checks placement before execution; a failed prerequisite skips its consumers with a reason. [Typed producers and dependencies →](Build/CAPABILITIES.md#producers-and-dependencies)
```fsharp let readRevision = executeCapture (Cmd.ofList "git" [ "rev-parse"; "HEAD" ]) |> Operation.map (fun result -> result.Stdout.Trim()) let revision = Producer.emptyDefine "revision" readRevision let pack project = Stage.consuming $"pack {project}" (DependencySpec.require revision) (fun sha -> cmd $"dotnet pack {project} -p:RepositoryCommit={sha}" |> execute) let release = pipeline "release" { pack "src/Core/Core.fsproj" pack "src/App/App.fsproj" } ```

One Git lookup, two consumers. The prerequisite is inferred from the dependency, rather than repeated in each stage.

## Start with the workflow you need ::::cards :::card title="Compose your workflow" href="/Partas.Build/build/composition/" Nest stages, yield lists, and share commands across files. Keep a reusable root in a long-lived F# session. ::: :::card title="Start with ready-made stages" href="/Partas.Build/build/baked/" Baked supplies restore, build, pack, test, and publish stages. ShipIt adds changelog-driven release workflows. ::: :::card title="Control execution" href="/Partas.Build/build/execution/" Set conditions, parallelism, timeouts, cleanup, and failure policies where the work runs. ::: ::::
## From a script to a hosted build Use a `.fsx` script or a build project. Keep a reusable root in a long-lived F# session and invoke it with typed results. Partas.Build targets `net10.0`, `net8.0`, and `netstandard2.0`. [Installation](Build/installation.md) · [Hosting](Build/hosting.md) · [ShipIt extension](extensions/shipit.md) · [External annotations](external-annotations/index.md)
--- # API reference | Package | | |---|---| | [`Partas.Build`](/Partas.Build/reference/partas-build/) | 48 declarations | | [`Partas.Build.EasyBuild.ShipIt`](/Partas.Build/reference/partas-build-easybuild-shipit/) | 6 declarations | --- # Release Notes ### 0.6.1-alpha.1 * Producers: `Producer.define name inputs dependencies execute` declares a typed, named unit of deferred work with its own CLI inputs and prerequisites. A stage requires one through `consumes (DependencySpec.require producer) (fun value -> ...)`, which also schedules it: the producer runs once per invocation and every consumer shares the result, so retrying a consumer re-runs only that consumer. `DependencySpec.map`/`map2`/`zip` combine several. * `onFailure` registers a failure handler on a stage, a pipeline or a command. It runs once per failed execution of its scope, after that scope's retries are exhausted, and reads the failure off `FailureContext.Primary`/ `Secondary` and a producer's published value off `FailureContext.TryGetOutput`, rather than off rendered text. * `PipelineContext.Reports` carries a `ScopeReport` per stage: the outcome, whether the failure propagates to the containing scope, and a `StepFailure` per cause naming the step. A `continueStageOnFailure` reports `Failed` with `Propagates = false` and keeps the cause, which lets a consumer tell a failed producer from a skipped one. * Three ways to run a `Cmd` from inside a step, when the step needs the result rather than the exit code: `execute` streams the output and fails on an exit code the stage does not accept, `executeCapture` captures instead and attaches the capture to the failure as evidence, and `attemptCapture` always answers a `CommandResult`, unaccepted exit codes included. `runOperation` adds any `Operation` as a step. * `timeoutForStep` is each step's own budget: a step takes it when it starts and runs its whole body under it, so a stage of several sequential steps gives each step the full budget rather than sharing one clock. * A timeout and a cancellation are told apart by which token fired. A stage's own `timeout`, and a `timeoutForStep` it gave a step, are failures of that stage and run its handlers; an ancestor's cancellation runs none. ### 0.5.0 * `Cmd` and the process runner ship as their own package, `Partas.Build.Cmd`, which `Partas.Build` references. * The ready-made options, the semver arithmetic and the bump stages ship as `Partas.Build.Baked`. A script that used them now takes a package reference on it; they are no longer part of `Partas.Build`. * `Cmd.run` answers a copy of its result rather than sharing one. * `--nuget-key` accepts `--nuget` and `-k` as aliases. ### 0.4.0-alpha.3 * An empty interpolation hole yields an empty argument rather than disappearing from the command line. * `runSensitive`'s flush no longer splits a masked hole across its delimiter. ### 0.4.0-alpha.2 * **Breaking.** `Input.mapFromAmong` and `Input.mapFromMany` replace `Input.choice` and `Input.choices`: an option over a fixed set of spellings, each bound to a typed value. ### 0.4.0-alpha.1 * `--explain` on every command prints the resolved stage tree and runs nothing. A grouping command lists the subcommands it dispatches to. * `--version` reports the pinned Partas.Build version. * A per-stage timing table is printed at the end of a run, nested by parentage and sized to the console. A quiet pipeline, and a run of a single stage, print none. * `retry` on a stage runs its steps again, up to `count` further attempts. The stage's `timeout` is the budget for the whole stage, retries included. * Each parallel branch buffers its output and flushes it as one block, so interleaved steps stay readable. * A step's label carries the command line it runs, which is what `--explain` shows for it. ### 0.3.0 * Command-level pipeline defaults: `workingDir`, `envVars`, the three timeouts, `acceptExitCodes`, the output operations, `post`, `runBeforeEachStage`/`runAfterEachStage` and `verbosity`/`verbose`/`quiet` are available on `command` and `rootCommand`. They are defaults, not overrides — a pipeline that sets the same thing wins. * `InputSpec<'T>` is published under `Partas.Build`, so a block can take one as a parameter. * `whenSome` and `whenOk` yield a stage only when the value is present, binding it for the stage to close over. * `Cmd.argIf` and `Cmd.argWhenSome` add an argument to a command line conditionally. * `name` on `rootCommand` sets what the root command calls itself, and script arguments are sliced by default. ### 0.2.0 - 0.2.3 * Bump stages in `Baked`: the version bump taken as a positional argument, or as `--bump`. * Overload fixes on `InputSpec`. ### 0.1.5 * Stage-level output sinks: `silentOutput`, `captureOutput`, `redirectOutput` and `outputTo` on both the stage and pipeline builders. A captured stage prints nothing and lifts what it held — stderr if the process wrote any, everything otherwise — into the error message when a step fails. * `StageContext.writeLine ctx stream line` is the routable way for a step to emit output; `echo` now uses it. * Error messages are percent-encoded into GitHub Actions annotations, so a multi-line failure survives one. * A step that failed with something to say reported nothing: the runner matched its error with an inverted guard, printing only the empty ones. Fixed, and covered by a test that records the console. * `Partas.Build.ExternalAnnotations` packs its MSBuild logic as `build/Partas.Build.ExternalAnnotations.targets`. NuGet only auto-imports `build/$(PackageId).targets`, so under its own name it imported nothing (NU5129). * Both of a redirected child's streams are always drained, which removes a pipe-buffer deadlock. ### 0.1.4 * `bump` command: `dotnet run --project Build.fsproj -- bump -p ...` rewrites `` and `` in the target project files. * Versions now live in the project files. Nothing on the pack path passes a version property, so a published package carries whatever the committed project file says. This file is a changelog only; no build step reads it. * `Baked.fs`: ready-made inputs (`--configuration`, `--nuget-key`, `--project`, `--ci`, `--bump`), semver arithmetic under `Version`, and `IO.writeVersion`/`IO.bumpVersion`. ### 0.1.3 * Initial release. --- # CLI Scripts in F# > Migrating the fantomas build and diagnostic scripts to Partas.Build Every F# repository of any size grows a handful of `.fsx` scripts: a build, a release, a few diagnostics. Each one wants a flag or two, and each one ends up scanning `fsi.CommandLineArgs`{fsharp} by hand, with a usage string typed to match. We're going to have a look at how `Partas.Build`{fsharp} can help by porting a repositories script tools. [fantomas](https://github.com/fsprojects/fantomas) is a good specimen: :::filetree - build.fsx - src/ - scripts - ast.fsx - BuildAnalyzers.fsx - BuildCommon.fsx - BuildCompiler.fsx - BuildRelease.fsx - BuildScripts.fsx - chain.fsx - format.fsx - oak.fsx - **shared.fsx** - writer-events.fsx ::: Each script runs on its own and is composed into `build.fsx` for CI. Between them they scan for `--dry-run`, `--signature`, `--define` and `--editorconfig`, sniff `CI` from the environment, and guard their entry points with a file-path comparison. ## Partas.Build Partas.Build is `Fun.Build`{fsharp}'s `stage`{fsharp}/`pipeline`{fsharp} DSL in front of `System.CommandLine`{fsharp}, with `FSharp.SystemCommandLine`{fsharp}'s input combinators for declaring options. It depends on none of the three; it absorbed the first and last. What it adds is that a stage declares the flags it reads, and the command line, its help, its validation and `--explain` derive from that. | Library | Flags | Help | Pipelines | |------------------------------------|---|---|---| | Hand-rolled | scanned from the argument array | typed by hand | none | | `Fun.Build`{fsharp} | scanned from the argument array | typed by hand | `stage`{fsharp} / `pipeline`{fsharp} | | `FSharp.SystemCommandLine`{fsharp} | declared | generated | none | | `Partas.Build`{fsharp} | declared by the stage that reads them | generated | `stage`{fsharp} / `pipeline`{fsharp} | The rest of this post ports the scripts for `fantomas` above, starting with the file they all load. ```fsharp title="scripts/shared.fsx" showLineNumbers collapse={1-25} {30-33} #r "../artifacts/bin/Fantomas.FCS/debug/Fantomas.FCS.dll" #r "../artifacts/bin/Fantomas.Core/debug/Fantomas.Core.dll" #r "nuget: editorconfig, 0.15.0" #load "../src/Fantomas/Suggestion.fs" #load "../src/Fantomas/EditorConfig.fs" open System.IO open Fantomas.Core open Fantomas.EditorConfig let parseEditorConfigContent (content: string) : FormatConfig = let tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) Directory.CreateDirectory(tempDir) |> ignore let editorConfigPath = Path.Combine(tempDir, ".editorconfig") let fsharpFile = Path.Combine(tempDir, "temp.fs") File.WriteAllText(editorConfigPath, $"root = true\n\n[*.fs]\n%s{content}") File.WriteAllText(fsharpFile, "") try match tryReadConfiguration fsharpFile with | Some result -> result.Config | None -> FormatConfig.Default finally Directory.Delete(tempDir, true) /// Parses args and returns (source, isSignature, config). /// Accepts either a file path as last arg, or source code via stdin. /// Optional flags: --editorconfig , --signature let parseArgs (args: string array) = let editorConfigIdx = args |> Array.tryFindIndex (fun a -> a = "--editorconfig") let hasSignatureFlag = args |> Array.exists (fun a -> a = "--signature") let defineIdx = args |> Array.tryFindIndex (fun a -> a = "--define") let config = match editorConfigIdx with | Some idx -> parseEditorConfigContent args.[idx + 1] | None -> FormatConfig.Default let defines = match defineIdx with | Some idx -> args.[idx + 1].Split(',') |> Array.toList | None -> [] // Collect flag indices to determine which arg (if any) is the input file let flagIndices = [| match editorConfigIdx with | Some idx -> yield idx yield idx + 1 | None -> () match defineIdx with | Some idx -> yield idx yield idx + 1 | None -> () yield! args |> Array.indexed |> Array.choose (fun (i, a) -> if a = "--signature" then Some i else None) |] let positionalArgs = args |> Array.indexed |> Array.filter (fun (i, _) -> not (Array.contains i flagIndices)) |> Array.map snd match Array.tryLast positionalArgs with | Some path when File.Exists(path) -> let sample = File.ReadAllText(path) let isSignature = hasSignatureFlag || path.EndsWith(".fsi") sample, isSignature, config, defines | _ -> let sample = stdin.ReadToEnd() sample, hasSignatureFlag, config, defines ``` ### Declaring Options #### Previously `shared.fsx` reads three flags and an input path, and hands the parsed values to every script that loads it. The flags first: ```fsharp title="scripts/shared.fsx" {2-4} let parseArgs args = let editorConfigIdx = args |> Array.tryFindIndex (fun a -> a = "--editorconfig") let hasSignatureFlag = args |> Array.exists (fun a -> a = "--signature") let defineIdx = args |> Array.tryFindIndex (fun a -> a = "--define") let config = match editorConfigIdx with | Some idx -> parseEditorConfigContent args.[idx + 1] | None -> FormatConfig.Default let defines = match defineIdx with | Some idx -> args.[idx + 1].Split(',') |> Array.toList | None -> [] ``` #### Partas.Build Three flags, three declarations. The **type parameter** is the parser: ```fsharp title="scripts/shared2.fsx" /Input\.option<[^>]+>/ /Input\.optionMaybe<[^>]+>/ #r "nuget: Partas.Build, 0.4.0-alpha.3" open Partas.Build module Options = let signature = Input.option "--signature" let defines = Input.option "--define" let editorConfig = Input.optionMaybe "--editorconfig" ``` :::note Input.optionMaybe `--editorconfig` may be absent, and the original falls back to a default config. An absent option is an `option`{fsharp}: ```fsharp title="scripts/shared2.fsx" ins=" option" ins="Maybe" let editorConfig: ActionInput = Input.optionMaybe "--editorconfig" ``` ::: --- ### Deriving Values at Bind Time #### Previously ```fsharp {2,6-9} let parseArgs args = let editorConfigIdx = args |> Array.tryFindIndex (fun a -> a = "--editorconfig") let hasSignatureFlag = args |> Array.exists (fun a -> a = "--signature") let defineIdx = args |> Array.tryFindIndex (fun a -> a = "--define") let config = match editorConfigIdx with | Some idx -> parseEditorConfigContent args.[idx + 1] | None -> FormatConfig.Default ``` `parseArgs`{fsharp} turns the raw string into a `FormatConfig`{fsharp} itself. #### Partas.Build `InputSpec.map`{fsharp} does the same thing once, where the option is declared, so nothing downstream ever sees the string: :::note ActionInput and InputSpec An `ActionInput<'T>`{fsharp} is one option or argument. An `InputSpec<'T>`{fsharp} is a list of inputs plus a function from a `ParseResult`{fsharp} to a `'T`{fsharp}. The list is readable before anything is parsed, which is how a command knows what to register; the function runs after. An `ActionInput`{fsharp} has no `map`{fsharp} because its value *is* the parsed option. `InputSpec.ofInput`{fsharp} lifts it to a spec of one input, and from there `map`{fsharp}, `and!`{fsharp} and `return`{fsharp} are ordinary. ::: ```fsharp title="scripts/shared2.fsx" ins={3-6} let editorConfig = Input.optionMaybe "--editorconfig" |> InputSpec.ofInput |> InputSpec.map (function | None -> FormatConfig.Default | Some content -> parseEditorConfigContent content) ``` --- ### Combining Inputs #### Previously The second half of `parseArgs`{fsharp} is index bookkeeping: collect the positions every flag occupies so the one token left over can be the file. #### Partas.Build Declare the argument instead and the parser finds it: ```fsharp title="scripts/shared2.fsx" "and!" {6-7} let inputPath = Input.argument "input" |> Input.description "input file or content" let inputContent = input { let! inputPath = inputPath and! signature = signature return if File.Exists inputPath then {| sample = File.ReadAllText inputPath isSignature = signature || inputPath.EndsWith ".fsi" |} else {| sample = inputPath; isSignature = signature |} } ``` `input { }`{fsharp} is applicative. `and!`{fsharp} unions the inputs of each binding, so `inputContent`{fsharp} is a value that *declares* `` and `--signature` and *reads* them, and whatever binds `inputContent`{fsharp} later inherits both without naming either. That is the whole of `shared2.fsx`: four declarations and one `input`{fsharp} block in place of `parseArgs`{fsharp}. :::::details Before/After ::::tabs :::tab Original ```fsharp title="scripts/shared.fsx" frame=none #r "../artifacts/bin/Fantomas.FCS/debug/Fantomas.FCS.dll" #r "../artifacts/bin/Fantomas.Core/debug/Fantomas.Core.dll" #r "nuget: editorconfig, 0.15.0" #load "../src/Fantomas/Suggestion.fs" #load "../src/Fantomas/EditorConfig.fs" open System.IO open Fantomas.Core open Fantomas.EditorConfig let parseEditorConfigContent (content: string) : FormatConfig = let tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) Directory.CreateDirectory(tempDir) |> ignore let editorConfigPath = Path.Combine(tempDir, ".editorconfig") let fsharpFile = Path.Combine(tempDir, "temp.fs") File.WriteAllText(editorConfigPath, $"root = true\n\n[*.fs]\n%s{content}") File.WriteAllText(fsharpFile, "") try match tryReadConfiguration fsharpFile with | Some result -> result.Config | None -> FormatConfig.Default finally Directory.Delete(tempDir, true) /// Parses args and returns (source, isSignature, config). /// Accepts either a file path as last arg, or source code via stdin. /// Optional flags: --editorconfig , --signature let parseArgs (args: string array) = let editorConfigIdx = args |> Array.tryFindIndex (fun a -> a = "--editorconfig") let hasSignatureFlag = args |> Array.exists (fun a -> a = "--signature") let defineIdx = args |> Array.tryFindIndex (fun a -> a = "--define") let config = match editorConfigIdx with | Some idx -> parseEditorConfigContent args.[idx + 1] | None -> FormatConfig.Default let defines = match defineIdx with | Some idx -> args.[idx + 1].Split(',') |> Array.toList | None -> [] // Collect flag indices to determine which arg (if any) is the input file let flagIndices = [| match editorConfigIdx with | Some idx -> yield idx yield idx + 1 | None -> () match defineIdx with | Some idx -> yield idx yield idx + 1 | None -> () yield! args |> Array.indexed |> Array.choose (fun (i, a) -> if a = "--signature" then Some i else None) |] let positionalArgs = args |> Array.indexed |> Array.filter (fun (i, _) -> not (Array.contains i flagIndices)) |> Array.map snd match Array.tryLast positionalArgs with | Some path when File.Exists(path) -> let sample = File.ReadAllText(path) let isSignature = hasSignatureFlag || path.EndsWith(".fsi") sample, isSignature, config, defines | _ -> let sample = stdin.ReadToEnd() sample, hasSignatureFlag, config, defines ``` ::: :::tab Ported ```fsharp title="scripts/shared2.fsx" frame=none #r "../artifacts/bin/Fantomas.FCS/debug/Fantomas.FCS.dll" #r "../artifacts/bin/Fantomas.Core/debug/Fantomas.Core.dll" #r "nuget: editorconfig, 0.15.0" #r "nuget: Partas.Build, 0.4.0-alpha.3" #load "../src/Fantomas/Suggestion.fs" #load "../src/Fantomas/EditorConfig.fs" open Partas.Build open System.IO open Fantomas.Core open Fantomas.EditorConfig module Utils = let runIfMain (name: string) (fn: unit -> int): unit = if Args.scriptName() |> ValueOption.exists ((=) name) then fn() |> exit module Options = let editorConfig= Input.optionMaybe "--editorconfig" |> InputSpec.ofInput |> InputSpec.map (function | None -> FormatConfig.Default | Some content -> let tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) Directory.CreateDirectory(tempDir) |> ignore let editorConfigPath = Path.Combine(tempDir, ".editorconfig") let fsharpFile = Path.Combine(tempDir, "temp.fs") File.WriteAllText(editorConfigPath, $"root = true\n\n[*.fs]\n%s{content}") File.WriteAllText(fsharpFile, "") try match tryReadConfiguration fsharpFile with | Some result -> result.Config | None -> FormatConfig.Default finally Directory.Delete(tempDir, true) ) let signature = Input.option "--signature" |> Input.alias "-s" let defines = Input.option "--define" |> Input.alias "-d" let inputPath = Input.argument "input" |> Input.description "input file or content" let inputContent = input { let! inputPath = inputPath and! signature = signature return if File.Exists(inputPath) then {| sample = File.ReadAllText(inputPath) isSignature = signature || inputPath.EndsWith(".fsi") |} else {| sample = inputPath isSignature = signature |} } ``` ::: :::: ::::: --- ### A Script Is a Root Command #### Previously The original `ast.fsx` ends with the entry-point dance every script repeats: ```fsharp title="scripts/ast.fsx" {9} match Array.tryHead fsi.CommandLineArgs with | Some scriptPath -> let scriptFile = FileInfo(scriptPath) let sourceFile = FileInfo(Path.Combine(__SOURCE_DIRECTORY__, __SOURCE_FILE__)) if scriptFile.FullName = sourceFile.FullName then let sample, isSignature, _, defines = parseArgs fsi.CommandLineArgs.[1..] parseAst sample isSignature defines |> printfn "%s" | _ -> printfn "Usage: dotnet fsi ast.fsx [--signature] [--define FOO,BAR] " ``` The usage string is typed by hand, so it is already stale: `--editorconfig` is parsed but not listed. #### Partas.Build `ast2.fsx`, in full: ```fsharp title="scripts/ast2.fsx" showLineNumbers {5-7} {17-25} #load "shared2.fsx" open Shared2 open Partas.Build let parseAst = input { let! inputContent = Options.inputContent and! defines = Options.defines return try Fantomas.FCS.Parse.parseFile inputContent.isSignature (SourceText.ofString inputContent.sample) defines |> fst |> sprintf "%A" with ex -> $"Error while parsing AST: %A{ex}" } Utils.runIfMain "ast2.fsx" <| fun () -> rootCommandOfScript { name "ast2.fsx" description "Parse ast" input { let! parsedAst = parseAst return stage "parsed ast" { echo parsedAst } } } ``` `Utils.runIfMain`{fsharp} is the "am I the script being run, or a `#load`{fsharp}" check, written once in `shared2.fsx`: ```fsharp title="scripts/shared2.fsx" let runIfMain (name: string) (fn: unit -> int) = if Args.scriptName () |> ValueOption.exists ((=) name) then fn () |> exit ``` What a contributor sees: ```bash frame=terminal "-s, --signature" "-d, --define " $ dotnet fsi scripts/ast2.fsx -- --help Description: Parse ast Usage: ast2.fsx [options] Arguments: input file or content Options: -s, --signature -d, --define --explain Print the resolved stage tree and exit, running nothing -?, -h, --help Show help and usage information --version Show version information ``` Nobody wrote that. `--signature` is a `bool`{fsharp} so it is a switch; `--define` is a `string list`{fsharp} so it takes values; the aliases came from `Input.alias`{fsharp}. The help cannot drift from the parser because it is the parser. `format2.fsx`, `oak2.fsx`, `writer-events2.fsx` and `chain2.fsx` are the same twenty lines with a different `input { }`{fsharp} block. `writer-events2.fsx` binds `Options.editorConfig`{fsharp} as well, so its `--help` has one more row: ```bash frame=terminal --editorconfig ``` :::tip `--define` is a `string list`{fsharp}, so it repeats: `-d FOO -d BAR`. ::: --- ## The Build Script #### Previously `build.fsx` is Fun.Build: nine pipelines, each ending in `runIfOnlySpecified`{fsharp}, with flags read the only way Fun.Build allows: ```fsharp title="build.fsx" {1-4,12,14} let isDryRun = fsi.CommandLineArgs |> Array.exists (fun arg -> arg = "--dry-run") let jsonFlagDuringCI: string = if String.IsNullOrEmpty(Environment.GetEnvironmentVariable "CI") then String.Empty else "--json" pipeline "Build" { workingDir __SOURCE_DIRECTORY__ stage "RestoreTools" { run "dotnet tool restore" } stage "CheckFormat" { run $"dotnet fantomas check src analyzers docs scripts build.fsx {jsonFlagDuringCI}" } // ... stage "Docs" { whenNot { platformOSX } envVars [| "DOTNET_ROLL_FORWARD_TO_PRERELEASE", "1"; "DOTNET_ROLL_FORWARD", "LatestMajor" |] run $"dotnet fsdocs build --clean --properties Configuration=Release --fscoptions \" -r:{semanticVersioning}\" --eval --strict --nonpublic" } runIfOnlySpecified false } ``` #### Partas.Build Partas.Build keeps `stage`{fsharp} and `pipeline`{fsharp} as they are. What changes is that a stage can bind a flag, and a command sits in front of the pipelines. ### Stages Declare Their Flags ```fsharp title="scripts/BuildCommon2.fsx" /let! \w+/ /and! \w+/ module Blocks = let restoreTools = input { let! quick = Options.quick return stage "restore tools" { when' (not quick) run "dotnet tool restore" } } let checkFormat = input { let! ci = Baked.Input.CI.isCI let json = if ci then "--json" else "" return stage "check format" { run $"dotnet fantomas check {formatTargets} {json}" } } let test = input { let! config = Options.config and! skip = Options.skipTests return stage "unit tests" { when' (not skip) run (cmd $"dotnet test -c {config} --tl") } } let fsdocs = input { let! watch = Options.watch let verb = if watch then "watch" else "build" return stage "fsdocs" { whenOSX false envVars [ "DOTNET_ROLL_FORWARD_TO_PRERELEASE", "1"; "DOTNET_ROLL_FORWARD", "LatestMajor" ] run (cmd $"dotnet fsdocs {verb} --properties Configuration=Release --fscoptions \" -r:{semanticVersioning}\" --eval --nonpublic") } } ``` :::info Baked inputs `Baked.Input.CI.isCI`{fsharp} is a stock `--ci` switch whose default is read from `CI`, `GITHUB_ACTIONS` and friends. The env sniffing is gone, and `--ci` on a laptop reproduces what the runner will do. ::: ### Commands Assemble Stages ```fsharp title="build2.fsx" /Blocks\.\w+/ {39-42} rootCommandOfScript { name "build2.fsx" description "Fantomas build" command "build" { description "Restore, check formatting, build, check scripts, test, pack and build the docs" workingDir repositoryRoot Blocks.restoreTools Blocks.clean [ analysisReportsDir; artifactsDir ] Blocks.checkFormat stage "build debug" { run (cmd $"dotnet build {scriptProject} --tl") } checkScripts Blocks.build checkDocScripts Blocks.test Blocks.pack Blocks.fsdocs } command "docs" { description "Build the docs, or serve them with --watch" workingDir repositoryRoot Blocks.restoreTools stage "build" { run "dotnet build -c Release src/Fantomas/Fantomas.fsproj --tl" } Blocks.fsdocs } command "repo-config" { description "Point git at the repository's hooks and blame settings" workingDir repositoryRoot stage "git" { run "git config core.hooksPath .githooks" run "git config blame.ignoreRevsFile .git-blame-ignore-revs" run "git config blame.markIgnoredLines true" } } BuildScripts2.commands BuildAnalyzers2.commands BuildRelease2.commands BuildCompiler2.commands } |> exit ``` `workingDir`{fsharp} on the root is a default every command inherits; a pipeline that sets its own wins. `runIfOnlySpecified`{fsharp} has no equivalent because a subcommand only runs when named. Flags travel up from the stages that read them: ```bash frame=terminal {9-13} $ dotnet fsi build2.fsx -- build --help Description: Restore, check formatting, build, test, pack and build the docs Usage: build2.fsx build [options] Options: -q, --quick Skip tool restore and clean --ci Indicates that the build is running in a CI environment; defaults to true if environment variables indicate so -c, --configuration [default: Release] --skip-tests Skip the unit tests --watch Serve the docs and rebuild on change --explain Print the resolved stage tree and exit, running nothing -?, -h, --help Show help and usage information ``` `docs --help` lists `--quick`, `--configuration` and `--watch` and nothing else, because those are the flags its three blocks bind. `format --help` lists none. Validation is System.CommandLine's: ```bash frame=terminal $ dotnet fsi build2.fsx -- build -c Relaese Argument 'Relaese' not recognized. Must be one of: 'Debug' 'Release' ``` ### `--explain` Every command that runs a pipeline gets `--explain`: resolve the stage tree against the flags given, print it, run nothing. ```bash frame=terminal "(skipped)" /" -r:[^"]*"/ $ dotnet fsi build2.fsx -- build --explain --quick --skip-tests build ├─ restore tools (skipped) │ └─ $ dotnet tool restore ├─ clean (skipped) │ └─ step 1 ├─ check format │ └─ $ dotnet fantomas check src analyzers docs scripts build.fsx ├─ build debug │ └─ $ dotnet build C:\...\src\Fantomas.Core\Fantomas.Core.fsproj --tl ├─ check scripts │ └─ step 1 ├─ build │ └─ $ dotnet build -c Release --tl ├─ check doc scripts │ └─ step 1 ├─ unit tests (skipped) │ └─ $ dotnet test -c Release --tl ├─ pack │ └─ $ dotnet pack --no-restore -c Release --tl └─ fsdocs └─ $ dotnet fsdocs build --properties Configuration=Release --fscoptions " -r:C:\...\SemanticVersioning.dll" --eval --nonpublic --clean --strict ``` Two things in that output are worth a second look. :::note Conditions carry a reason `whenOSX false`{fsharp} prints `(skipped: the host is OSX)` on a Mac; `when' (not quick)`{fsharp} takes a bare `bool`{fsharp} and so prints only `(skipped)`. Use the structured conditions where you want the tree to say why. ::: :::note One hole, one argument The `-r:` argument is quoted and the `fantomas check` targets are not. `cmd $"..."`{fsharp} is a `FormattableString`{fsharp}: literal text is split on whitespace, each interpolated value becomes exactly one argument, and the list goes to `ProcessStartInfo.ArgumentList`{fsharp}. No shell, no escaping, and a path with a space in it is not a bug you find on someone else's machine. `run "..."`{fsharp} with a plain string is the shell-split form for when the text is all literal. ::: --- ### One File, Library and CLI #### Previously `build.fsx` loads four `Build*.fsx` files in a fixed order, and each of them opens with a guard that exits if it was run directly. The ports load what they need themselves and end the same way: ```fsharp title="scripts/BuildRelease2.fsx" {10-14} #load "BuildCommon2.fsx" let commands = [ command "release" { (* ... *) } command "publish-alpha" { (* ... *) } command "push-client" { (* ... *) } ] runIfMain "BuildRelease2.fsx" (fun () -> rootCommandOfScript { name "BuildRelease2.fsx" commands }) ``` #### Partas.Build `build2.fsx` yields `BuildRelease2.commands`{fsharp} and gets the three subcommands. `dotnet fsi scripts/BuildRelease2.fsx -- release --dry-run` gets the same three and nothing else. A command is a value, and a list of them is yieldable wherever one is. ### Building a Command Line `pushPackage`{fsharp} in the original reads `NUGET_KEY` from the environment and interpolates it into a string. The port takes the key as a value and masks it: ```diff lang="fsharp" title="scripts/BuildRelease2.fsx" -let pushPackage (dryRun: bool) (nupkg: string) : Async = +let pushPackage (key: string option) (dryRun: bool) (nupkg: string) : Async = + let push = + cmd $"dotnet nuget push {nupkg} --source https://api.nuget.org/v3/index.json" + |> Cmd.secretOptionWhenSome "--api-key" key + if dryRun then - printfn $"[DRY-RUN] Would push package: {nupkg}" + printfn $"[DRY-RUN] Would run: {Cmd.toLogString push}" async { return 0 } else - let key = Environment.GetEnvironmentVariable("NUGET_KEY") - Cli.Wrap("dotnet") - .WithArguments($"nuget push \"{nupkg}\" --api-key \"{key}\" --source https://api.nuget.org/v3/index.json") - .ExecuteAsync() + Proc.stream push ``` ```bash frame=terminal "--api-key ***" $ dotnet fsi build2.fsx -- publish-alpha --dry-run --quick --nuget-key oy2abc [DRY-RUN] Would run: dotnet nuget push C:\...\fantomas.8.0.0-beta-001.nupkg --source https://api.nuget.org/v3/index.json --api-key *** [DRY-RUN] Would run: dotnet nuget push C:\...\Fantomas.Core.8.0.0-beta-001.nupkg --source https://api.nuget.org/v3/index.json --api-key *** [DRY-RUN] Would run: dotnet nuget push C:\...\Fantomas.FCS.8.0.0-beta-001.nupkg --source https://api.nuget.org/v3/index.json --api-key *** ``` The key comes from `--nuget-key`, whose default is the environment variable, so the flag exists for a laptop and the variable for the runner: ```fsharp title="scripts/BuildCommon2.fsx" let nugetKey = Input.optionMaybe "--nuget-key" |> Input.desc "NuGet API key; defaults to NUGET_KEY" |> Input.def (Environment.GetEnvironmentVariable "NUGET_KEY" |> Option.ofObj) ``` A conditional flag is an `argIf`{fsharp} rather than a second copy of the line. The `gh release create` call in the original assembles `isDraftFlag`{fsharp} and `prereleaseFlag`{fsharp} strings, each empty or not, and interpolates both: ::::tabs before-after :::tab build.fsx ```fsharp title="build.fsx" {1-5} {8} let isDraftFlag = if isRevision || isPrerelease then String.Empty else "--draft" let prereleaseFlag = if isPrerelease then "--prerelease" else String.Empty let releaseCommand = $"release create v{currentRelease.Version} {files} {isDraftFlag} {prereleaseFlag} --title \"{currentRelease.Title}\" --notes-file \"{noteFile}\"" ``` ::: :::tab BuildRelease2.fsx ```fsharp title="scripts/BuildRelease2.fsx" ins={4-5} let releaseCommand = cmd $"gh release create v{currentRelease.Version} --title {currentRelease.Title} --notes-file {noteFile}" |> Cmd.args (List.ofArray nugetPackages) |> Cmd.argIf isDraft [ "--draft" ] |> Cmd.argIf isPrerelease [ "--prerelease" ] ``` ::: :::: `--title` takes a value with spaces in it, and it is one argument because it is one hole. ### A Step That Decides Whether to Run `FormatChanged` asks git for the changed files and either prints a message or runs fantomas over them. In Fun.Build that meant a `CliWrap` call inside the step, with output piping wired by hand. A step can instead return the command, or nothing: ```fsharp title="build2.fsx" "Result" {9-10} stage "format" { run (fun _ -> async { let! files = changedFiles () match List.filter (hasExtension [ ".fs"; ".fsx"; ".fsi" ]) files with | [] -> printfn "No changed F# files to format." return (Ok None: Result) | sources -> return Ok(Some(cmd $"dotnet fantomas --json" |> Cmd.args sources)) }) } ``` The runner starts whatever comes back, streams its output like any other step, and maps its exit code through the stage's accepted codes. ### Timings A run ends with the per-stage table; skipped stages say so instead of vanishing. ```text frame=terminal "skipped" Stage timings ╭──────────┬──────┬─────────╮ │ Stage │ Time │ Outcome │ ├──────────┼──────┼─────────┤ │ versions │ 0.1s │ ok │ │ restore │ - │ skipped │ │ nested │ 0.0s │ ok │ │ inner │ 0.0s │ ok │ ╰──────────┴──────┴─────────╯ ``` ## What Went Away - `parseArgs`{fsharp}, and one hand-written usage string per script. - The `fsi.CommandLineArgs`{fsharp} entry-point check, replaced by one `runIfMain`{fsharp} in `shared2.fsx`. - `isDryRun`{fsharp}, `jsonFlagDuringCI`{fsharp} and every other flag read by scanning the argument array. - The CliWrap dependency. `run`{fsharp} streams a child process's output already, and the places that read a process's output as a value share a forty-line `Proc`{fsharp} module over `Cmd`{fsharp}. - The `runIfOnlySpecified`{fsharp} guards, and the "do not run this file directly" guards in front of them. The ported tree, with the new files in bold: :::filetree - **build2.fsx** - scripts - **ast2.fsx** - **BuildAnalyzers2.fsx** - **BuildCommon2.fsx** - **BuildCompiler2.fsx** - **BuildRelease2.fsx** - **BuildScripts2.fsx** - **chain2.fsx** - **format2.fsx** - **oak2.fsx** - **shared2.fsx** - **writer-events2.fsx** ::: What stayed is the part worth keeping. `stage`{fsharp}, `pipeline`{fsharp}, `whenNot`{fsharp}, `envVars`{fsharp}, `parallel'`{fsharp} and `timeout`{fsharp} are Fun.Build's, and a Fun.Build script ports stage by stage. The difference is that the flags a stage reads are now part of its type, and the command line, its help, its validation and `--explain` all fall out of that. ## Appendix: Every File, Before and After The tabs are synced: pick *Ported* once and every file shows its port. ::::::tabs files :::::tab build.fsx ::::tabs before-after :::tab Original ```fsharp title="build.fsx" showLineNumbers collapse={273-361} #!/usr/bin/env -S dotnet fsi -- #r "nuget: Fun.Build, 1.1.18" #r "nuget: CliWrap, 3.6.4" #r "nuget: FSharp.Data, 6.3.0" #r "nuget: Ionide.KeepAChangelog, 0.1.8" #r "nuget: Humanizer.Core, 2.14.1" // The build is split across these, and they are loaded in dependency order: each expects the ones // above it to be in scope and does not load them itself. Loading a file twice would compile it // twice, and two copies of a type are two different types, so the order lives here and nowhere else. #load "scripts/BuildCommon.fsx" #load "scripts/BuildScripts.fsx" #load "scripts/BuildAnalyzers.fsx" #load "scripts/BuildRelease.fsx" #load "scripts/BuildCompiler.fsx" open System open System.IO open Fun.Build open CliWrap open BuildCommon open BuildScripts open BuildAnalyzers open BuildRelease open BuildCompiler /// Every test project, by name. Each writes its raw coverage beside its own project file. let coverageProjects: string list = [ "Fantomas.Core.Tests"; "Fantomas.Tests"; "Fantomas.Client.Tests" ] let coverageXmlFiles: string list = coverageProjects |> List.map (fun (name: string) -> __SOURCE_DIRECTORY__ "src" name "coverage.xml") /// Run one test project under AltCover, measuring the one assembly it is there to exercise. /// /// The filter is a negative lookahead: instrument that assembly and nothing else, which keeps the /// generated Fantomas.FCS parser and the test assembly itself out of the report and makes the run /// fast. It cannot name several assemblies at once, because AltCover reads `|` as the separator /// between filters rather than as alternation, so each project is run with its own. let coverageCommand (name: string) (assemblyPattern: string) : string = let project: string = __SOURCE_DIRECTORY__ "src" name $"{name}.fsproj" $"dotnet test {project} -c Release /p:AltCover=true " + $"\"/p:AltCoverAssemblyFilter=^(?!{assemblyPattern}$)\"" let benchmarkAssembly = binDir "Fantomas.Benchmarks" "release" "Fantomas.Benchmarks.dll" let semanticVersioning = binDir "Fantomas" "release" "SemanticVersioning.dll" let isDryRun = let args = fsi.CommandLineArgs Array.exists (fun arg -> arg = "--dry-run") args /// `--json` when a hosted runner is what is reading the output, and nothing when a person is. /// /// GitHub Actions sets `CI` to `true`, and so does nearly every other hosted runner; nothing sets /// it on a development machine. The value itself is not worth matching on, only whether it is /// there, because no two runners agree on what to put in it. let jsonFlagDuringCI: string = if String.IsNullOrEmpty(Environment.GetEnvironmentVariable "CI") then String.Empty else "--json" pipeline "Build" { workingDir __SOURCE_DIRECTORY__ stage "RestoreTools" { run "dotnet tool restore" } stage "Clean" { run (cleanFolders [| analysisReportsDir; artifactsDir |]) } stage "CheckFormat" { run $"dotnet fantomas check src analyzers docs scripts build.fsx {jsonFlagDuringCI}" } stage "BuildDebug" { run $"dotnet build \"{scriptProject}\" --tl" } stage "CheckScripts" { run checkScripts } stage "Build" { run "dotnet build -c Release --tl" } stage "CheckDocScripts" { run checkDocScripts } stage "UnitTests" { run "dotnet test -c Release --tl" } stage "Pack" { run "dotnet pack --no-restore -c Release --tl" } stage "Docs" { whenNot { platformOSX } envVars [| "DOTNET_ROLL_FORWARD_TO_PRERELEASE", "1" "DOTNET_ROLL_FORWARD", "LatestMajor" |] run $"dotnet fsdocs build --clean --properties Configuration=Release --fscoptions \" -r:{semanticVersioning}\" --eval --strict --nonpublic" } runIfOnlySpecified false } pipeline "Benchmark" { workingDir __SOURCE_DIRECTORY__ stage "Prepare" { run "dotnet build -c Release src/Fantomas.Benchmarks --tl" } stage "Benchmark" { run $"dotnet \"{benchmarkAssembly}\"" } runIfOnlySpecified true } // Line and branch coverage for the three projects Fantomas ships, via AltCover's MSBuild // integration. Every test project is run under AltCover, each measuring the one assembly it is // there to exercise, and ReportGenerator merges the three results into a single report. // // So `Fantomas.Core`'s figure comes from `Fantomas.Core.Tests` alone, even though `Fantomas.Tests` // exercises Core heavily through real formatting. Core is understated here rather than wrong. // // The filter is a negative lookahead naming the three assemblies to instrument. Everything else // is left alone, which keeps the generated Fantomas.FCS parser and the test assemblies // themselves out of the report. AltCover writes OpenCover XML, which is for tooling rather than // reading, so ReportGenerator turns it into a browsable HTML report afterwards. // // A test that starts the fantomas process, as those in Fantomas.Tests/Integration do, adds // nothing here, because the child process is not instrumented. That is the point rather than a // flaw: what this measures is how much of the tool can be reached without starting one. // // Produces: // src//coverage.xml raw OpenCover XML, one per test project // coveragereport/index.html browsable report, per file and per line pipeline "Coverage" { workingDir __SOURCE_DIRECTORY__ stage "RestoreTools" { run "dotnet tool restore" } stage "Clean" { run (cleanFolders [| coverageReportDir |]) // A stale coverage.xml from an earlier run would otherwise be merged into the report. run (fun _ -> async { for file in coverageXmlFiles do if File.Exists file then File.Delete file return 0 }) } stage "Coverage" { run (coverageCommand "Fantomas.Core.Tests" @"Fantomas\.Core") run (coverageCommand "Fantomas.Tests" "fantomas") run (coverageCommand "Fantomas.Client.Tests" @"Fantomas\.Client") } stage "Report" { run ( $"dotnet reportgenerator -reports:{String.Join(';', coverageXmlFiles)} " + $"-targetdir:{coverageReportDir} -reporttypes:Html;TextSummary" ) run (fun _ -> async { let summary = coverageReportDir "Summary.txt" let index = coverageReportDir "index.html" if File.Exists summary then printfn "%s" (File.ReadAllText summary) printfn $"Browse the full report at {index}" return 0 }) } runIfOnlySpecified true } pipeline "FormatChanged" { workingDir __SOURCE_DIRECTORY__ stage "Format" { run (fun _ -> async { let! files = changedFiles () let sources: string list = List.filter (hasExtension [ ".fs"; ".fsx"; ".fsi" ]) files match sources with | [] -> printfn "No changed F# files to format." return 0 | sources -> let arguments: string = sources |> List.map (fun (source: string) -> $"\"{source}\"") |> String.concat " " // CliWrap discards the child's output unless it is given somewhere to put // it, and what fantomas has to say about the files is the point of the run. let toConsole: PipeTarget = PipeTarget.ToDelegate(fun (line: string) -> printfn "%s" line) let! result = Cli .Wrap("dotnet") .WithArguments($"fantomas --json {arguments}") .WithStandardOutputPipe(toConsole) .WithStandardErrorPipe(toConsole) .WithValidation(CommandResultValidation.None) .ExecuteAsync() .Task |> Async.AwaitTask return result.ExitCode }) } runIfOnlySpecified true } pipeline "PushClient" { workingDir __SOURCE_DIRECTORY__ stage "Pack" { run "dotnet pack ./src/Fantomas.Client -c Release --tl" } stage "Push" { run (fun _ -> async { return! Directory.EnumerateFiles(packagesDir, "Fantomas.Client.*.nupkg", SearchOption.TopDirectoryOnly) |> Seq.tryExactlyOne |> Option.map pushPackage |> Option.defaultValue ( async { printfn "Fantomas.Client package was not found." return -1 } ) }) } runIfOnlySpecified true } pipeline "Docs" { workingDir __SOURCE_DIRECTORY__ stage "Prepare" { run "dotnet tool restore" run "dotnet build -c Release src/Fantomas/Fantomas.fsproj" } stage "Watch" { envVars [| "DOTNET_ROLL_FORWARD_TO_PRERELEASE", "1" "DOTNET_ROLL_FORWARD", "LatestMajor" |] run $"dotnet fsdocs watch --properties Configuration=Release --fscoptions \" -r:{semanticVersioning}\" --eval --nonpublic" } runIfOnlySpecified true } pipeline "FormatAll" { workingDir __SOURCE_DIRECTORY__ stage "Fantomas" { run "dotnet fantomas --json src analyzers docs scripts build.fsx" } runIfOnlySpecified true } pipeline "EnsureRepoConfig" { workingDir __SOURCE_DIRECTORY__ stage "Git" { run "git config core.hooksPath .githooks" // Without this, `.git-blame-ignore-revs` is a file git only reads when asked to on the // command line. GitHub's blame view honours it on its own; a clone does not. run "git config blame.ignoreRevsFile .git-blame-ignore-revs" // Mark a line whose real author had to be guessed past an ignored commit, so a skipped // attribution is not read as a genuine one. run "git config blame.markIgnoredLines true" } runIfOnlySpecified true } pipeline "Init" { workingDir __SOURCE_DIRECTORY__ stage "Download FCS files" { run (fun _ -> [| // Not a compiler source. This is the MSBuild task that turns FSComp.txt into the SR // module. Since dotnet/fsharp#20097 the generated diagnostic accessors return RichText // instead of string, and the task shipped in the .NET SDK cannot generate those yet. "src/FSharp.Build/FSharpEmbedResourceText.fs" "src/Compiler/FSComp.txt" "src/Compiler/FSStrings.resx" "src/Compiler/Utilities/NullHelpers.fs" "src/Compiler/Utilities/Activity.fsi" "src/Compiler/Utilities/Activity.fs" "src/Compiler/Utilities/Caches.fsi" "src/Compiler/Utilities/Caches.fs" "src/Compiler/Utilities/sformat.fsi" "src/Compiler/Utilities/sformat.fs" "src/Compiler/Utilities/sr.fsi" "src/Compiler/Utilities/sr.fs" "src/Compiler/Facilities/RichText.fsi" "src/Compiler/Facilities/RichText.fs" "src/Compiler/Utilities/ResizeArray.fsi" "src/Compiler/Utilities/ResizeArray.fs" "src/Compiler/Utilities/HashMultiMap.fsi" "src/Compiler/Utilities/HashMultiMap.fs" "src/Compiler/Utilities/ReadOnlySpan.fsi" "src/Compiler/Utilities/ReadOnlySpan.fs" "src/Compiler/Utilities/TaggedCollections.fsi" "src/Compiler/Utilities/TaggedCollections.fs" "src/Compiler/Utilities/illib.fsi" "src/Compiler/Utilities/illib.fs" "src/Compiler/Utilities/Cancellable.fsi" "src/Compiler/Utilities/Cancellable.fs" "src/Compiler/Utilities/FileSystem.fsi" "src/Compiler/Utilities/FileSystem.fs" "src/Compiler/Utilities/ildiag.fsi" "src/Compiler/Utilities/ildiag.fs" "src/Compiler/Utilities/zmap.fsi" "src/Compiler/Utilities/zmap.fs" "src/Compiler/Utilities/zset.fsi" "src/Compiler/Utilities/zset.fs" "src/Compiler/Utilities/XmlAdapters.fsi" "src/Compiler/Utilities/XmlAdapters.fs" "src/Compiler/Utilities/InternalCollections.fsi" "src/Compiler/Utilities/InternalCollections.fs" "src/Compiler/Utilities/lib.fsi" "src/Compiler/Utilities/lib.fs" "src/Compiler/Utilities/PathMap.fsi" "src/Compiler/Utilities/PathMap.fs" "src/Compiler/Utilities/range.fsi" "src/Compiler/Utilities/range.fs" "src/Compiler/Facilities/LanguageFeatures.fsi" "src/Compiler/Facilities/LanguageFeatures.fs" "src/Compiler/Facilities/DiagnosticOptions.fsi" "src/Compiler/Facilities/DiagnosticOptions.fs" "src/Compiler/Facilities/DiagnosticsLogger.fsi" "src/Compiler/Facilities/DiagnosticsLogger.fs" "src/Compiler/Facilities/Hashing.fsi" "src/Compiler/Facilities/Hashing.fs" "src/Compiler/Facilities/prim-lexing.fsi" "src/Compiler/Facilities/prim-lexing.fs" "src/Compiler/Facilities/prim-parsing.fsi" "src/Compiler/Facilities/prim-parsing.fs" "src/Compiler/AbstractIL/illex.fsl" "src/Compiler/AbstractIL/ilpars.fsy" "src/Compiler/AbstractIL/il.fsi" "src/Compiler/AbstractIL/il.fs" "src/Compiler/AbstractIL/ilascii.fsi" "src/Compiler/AbstractIL/ilascii.fs" "src/Compiler/SyntaxTree/PrettyNaming.fsi" "src/Compiler/SyntaxTree/PrettyNaming.fs" "src/Compiler/pplex.fsl" "src/Compiler/pppars.fsy" "src/Compiler/lex.fsl" "src/Compiler/pars.fsy" "src/Compiler/SyntaxTree/UnicodeLexing.fsi" "src/Compiler/SyntaxTree/UnicodeLexing.fs" "src/Compiler/SyntaxTree/XmlDocIncludeExpander.fsi" "src/Compiler/SyntaxTree/XmlDocIncludeExpander.fs" "src/Compiler/SyntaxTree/XmlDoc.fsi" "src/Compiler/SyntaxTree/XmlDoc.fs" "src/Compiler/SyntaxTree/SyntaxTrivia.fsi" "src/Compiler/SyntaxTree/SyntaxTrivia.fs" "src/Compiler/SyntaxTree/SyntaxTree.fsi" "src/Compiler/SyntaxTree/SyntaxTree.fs" "src/Compiler/SyntaxTree/SyntaxTreeOps.fsi" "src/Compiler/SyntaxTree/SyntaxTreeOps.fs" "src/Compiler/SyntaxTree/WarnScopes.fsi" "src/Compiler/SyntaxTree/WarnScopes.fs" "src/Compiler/SyntaxTree/LexerStore.fsi" "src/Compiler/SyntaxTree/LexerStore.fs" "src/Compiler/SyntaxTree/ParseHelpers.fsi" "src/Compiler/SyntaxTree/ParseHelpers.fs" "src/Compiler/SyntaxTree/LexHelpers.fsi" "src/Compiler/SyntaxTree/LexHelpers.fs" "src/Compiler/SyntaxTree/LexFilter.fsi" "src/Compiler/SyntaxTree/LexFilter.fs" |] |> Array.map (downloadCompilerFile fsharpCompilerHash) |> Async.Parallel |> Async.Ignore) } runIfOnlySpecified true } pipeline "Release" { workingDir __SOURCE_DIRECTORY__ stage "Build" { run "dotnet build -c Release" } stage "UnitTests" { run "dotnet test -c Release" } stage "Pack" { run "dotnet pack -c Release" } stage "Release" { run (fun _ -> async { if isDryRun then printfn "[DRY-RUN] Starting release pipeline in dry-run mode" else printfn "Starting release pipeline" let currentRelease, lastPublishedDate = getCurrentReleaseAndLastPublishedDate () if Option.isSome currentRelease.PublishedDate then printfn $"Release {currentRelease.Version} already exists on GitHub. Skipping release process." return 0 else printfn $"Release {currentRelease.Version} does not exist yet. Proceeding with release process." // Determine if this is a prerelease let isPrerelease = currentRelease.Version.Contains("-") if isPrerelease then printfn $"Detected prerelease version: {currentRelease.Version}" // Push packages to NuGet let nugetPackages = Directory.EnumerateFiles(packagesDir, "*.nupkg", SearchOption.TopDirectoryOnly) |> Seq.filter (fun nupkg -> not (nupkg.Contains("Fantomas.Client"))) |> Seq.toArray printfn $"Found {nugetPackages.Length} packages to push to NuGet:" nugetPackages |> Array.iter (fun pkg -> printfn $" - {Path.GetFileName(pkg)}") let! nugetExitCodes = nugetPackages |> Array.map pushPackage |> Async.Sequential let nugetSuccess = nugetExitCodes |> Array.forall (fun code -> code = 0) if nugetSuccess then printfn "All NuGet packages pushed successfully" else let exitCodesStr = nugetExitCodes |> Array.map string |> String.concat ", " printfn $"Warning: Some NuGet packages failed to push. Exit codes: {exitCodesStr}" let notes = getReleaseNotes currentRelease lastPublishedDate printfn "Release notes that will be used:" printfn "---" printfn "%s" notes printfn "---" let noteFile = Path.GetTempFileName() File.WriteAllText(noteFile, notes) let files = nugetPackages |> Array.map (sprintf "\"%s\"") |> String.concat " " // We create a draft release for minor and majors. Those that requires a manual publish. // This is to allow us to add additional release notes when it makes sense. // Extract patch version from currentRelease.Version (handle prerelease format) let versionParts = currentRelease.Version.Split('-') let mainVersion = versionParts.[0] let patchVersion = let parts = mainVersion.Split('.') if parts.Length >= 3 then match Int32.TryParse(parts.[2]) with | true, p -> p | _ -> 0 else 0 let isRevision = patchVersion <> 0 // Draft only for stable minor/major releases (patch = 0 and not prerelease) let isDraftFlag = if isRevision || isPrerelease then String.Empty else "--draft" let prereleaseFlag = if isPrerelease then "--prerelease" else String.Empty let releaseType = if isPrerelease then "prerelease (published)" elif isRevision then "revision (published)" else "minor/major (draft)" printfn $"Release type: {releaseType}" if isPrerelease then printfn "This is a prerelease version" let releaseCommand = $"release create v{currentRelease.Version} {files} {isDraftFlag} {prereleaseFlag} --title \"{currentRelease.Title}\" --notes-file \"{noteFile}\"" let! draftExitCode = if isDryRun then printfn $"[DRY-RUN] Would execute: gh {releaseCommand}" async { return 0 } else printfn $"Creating GitHub release: v{currentRelease.Version}" async { let! result = Cli .Wrap("gh") .WithArguments(releaseCommand) .WithValidation(CommandResultValidation.None) .ExecuteAsync() .Task |> Async.AwaitTask return result.ExitCode } if File.Exists noteFile then File.Delete(noteFile) if draftExitCode = 0 then printfn $"Successfully created GitHub release: v{currentRelease.Version}" else printfn $"Warning: GitHub release creation returned exit code: {draftExitCode}" return Seq.max [| yield! nugetExitCodes; yield draftExitCode |] }) } runIfOnlySpecified true } pipeline "PublishAlpha" { workingDir __SOURCE_DIRECTORY__ stage "Clean" { run (cleanFolders [| analysisReportsDir; artifactsDir |]) } stage "Build" { run "dotnet build -c Release --tl" } stage "Pack" { run "dotnet pack --no-restore -c Release --tl" } stage "Publish" { run (fun ctx -> async { let nugetPackages = Directory.EnumerateFiles(packagesDir, "*.nupkg", SearchOption.TopDirectoryOnly) |> Seq.filter (fun nupkg -> not (nupkg.Contains("Fantomas.Client"))) |> Seq.toArray let! nugetExitCodes = nugetPackages |> Array.map pushPackage |> Async.Sequential return Seq.sum nugetExitCodes }) } runIfOnlySpecified true } pipeline "Analyze" { workingDir __SOURCE_DIRECTORY__ stage "RestoreTools" { run "dotnet tool restore" } stage "RestoreSolution" { run "dotnet restore --tl" } stage "BuildAnalyzers" { run buildLocalAnalyzers } stage "Analyze" { run (fun _ -> projectsToAnalyze |> List.map (fun (project: string) -> { Project = project; Files = [] }) |> analyzeTargets excludeLocalAdvisory everyFinding) } runIfOnlySpecified true } // The same analyzers, over the files the working tree touches. // // A project is only loaded when it owns a changed file, and is then analyzed for that file alone, // which is the difference between minutes and seconds on the test projects. What this cannot see // is a finding a change causes in a file other than the ones you edited, which is what the full // `Analyze` pipeline is still for before opening a pull request. pipeline "AnalyzeChanged" { workingDir __SOURCE_DIRECTORY__ stage "RestoreTools" { run "dotnet tool restore" } stage "RestoreSolution" { run "dotnet restore --tl" } stage "BuildAnalyzers" { run buildLocalAnalyzers } stage "Analyze" { run (fun _ -> async { let! files = changedFiles () // Everything reports and nothing fails. Warning rather than something lower // because these are still findings to act on, and the tool prints every severity // either way; the only thing being given up here is the non-zero exit. let demoteLocalErrors: string list = "--treat-as-warning" :: localErrorRules match targetsFor files with | [] -> printfn "No changed file belongs to a project that is analyzed." return 0 | targets -> let! scopes = changedLines () return! analyzeTargets demoteLocalErrors (keepFinding scopes) targets }) } runIfOnlySpecified true } tryPrintPipelineCommandHelp () ``` ::: :::tab Ported ```fsharp title="build2.fsx" showLineNumbers #!/usr/bin/env -S dotnet fsi -- // Each file below is a library when loaded and a CLI when run directly, and each loads what it // needs itself, so the order here does not matter. #load "scripts/BuildCommon2.fsx" #load "scripts/BuildScripts2.fsx" #load "scripts/BuildAnalyzers2.fsx" #load "scripts/BuildRelease2.fsx" #load "scripts/BuildCompiler2.fsx" open System open System.IO open Partas.Build open BuildCommon2 open BuildScripts2 let formatTargets = "src analyzers docs scripts build.fsx" let semanticVersioning = binDir "Fantomas" "release" "SemanticVersioning.dll" let benchmarkAssembly = binDir "Fantomas.Benchmarks" "release" "Fantomas.Benchmarks.dll" module Options = let watch = Input.option "--watch" |> Input.desc "Serve the docs and rebuild on change" module Blocks = let checkFormat = input { let! ci = Options.ci let json = if ci then "--json" else "" return stage "check format" { run $"dotnet fantomas check {formatTargets} {json}" } } let fsdocs = input { let! watch = Options.watch let verb = if watch then "watch" else "build" return stage "fsdocs" { whenOSX false envVars [ "DOTNET_ROLL_FORWARD_TO_PRERELEASE", "1" "DOTNET_ROLL_FORWARD", "LatestMajor" ] run ( cmd $"dotnet fsdocs {verb} --properties Configuration=Release --fscoptions \" -r:{semanticVersioning}\" --eval --nonpublic" |> Cmd.argIf (not watch) [ "--clean"; "--strict" ] ) } } /// Every test project, by name. Each writes its raw coverage beside its own project file. let coverageProjects: string list = [ "Fantomas.Core.Tests"; "Fantomas.Tests"; "Fantomas.Client.Tests" ] let coverageXmlFiles: string list = coverageProjects |> List.map (fun (name: string) -> repositoryRoot "src" name "coverage.xml") /// One test project under AltCover, instrumenting only the assembly it is there to exercise. let coverageCommand (name: string) (assemblyPattern: string) : Cmd = let project: string = repositoryRoot "src" name $"{name}.fsproj" cmd $"dotnet test {project} -c Release /p:AltCover=true /p:AltCoverAssemblyFilter=^(?!{assemblyPattern}$)" rootCommandOfScript { name "build2.fsx" description "Fantomas build" command "build" { description "Restore, check formatting, build, check scripts, test, pack and build the docs" workingDir repositoryRoot Blocks.restoreTools Blocks.clean [ analysisReportsDir; artifactsDir ] Blocks.checkFormat stage "build debug" { run (cmd $"dotnet build {scriptProject} --tl") } checkScripts Blocks.build checkDocScripts Blocks.test Blocks.pack Blocks.fsdocs } command "benchmark" { description "Build and run the benchmarks" workingDir repositoryRoot stage "prepare" { run "dotnet build -c Release src/Fantomas.Benchmarks --tl" } stage "benchmark" { run (cmd $"dotnet {benchmarkAssembly}") } } command "coverage" { description "Line and branch coverage of the three shipped projects, merged into coveragereport/" workingDir repositoryRoot Blocks.restoreTools stage "clean" { run (cleanFolders [ coverageReportDir ]) run (fun _ -> for file in coverageXmlFiles do if File.Exists file then File.Delete file) } stage "coverage" { run (coverageCommand "Fantomas.Core.Tests" @"Fantomas\.Core") run (coverageCommand "Fantomas.Tests" "fantomas") run (coverageCommand "Fantomas.Client.Tests" @"Fantomas\.Client") } stage "report" { run ( cmd $"dotnet reportgenerator -reports:{String.Join(';', coverageXmlFiles)} -targetdir:{coverageReportDir} -reporttypes:Html;TextSummary" ) run (fun _ -> let summary = coverageReportDir "Summary.txt" if File.Exists summary then printfn "%s" (File.ReadAllText summary) let index = coverageReportDir "index.html" printfn $"Browse the full report at {index}") } } command "format-changed" { description "Format the F# files the working tree changed" workingDir repositoryRoot stage "format" { run (fun _ -> async { let! files = changedFiles () match List.filter (hasExtension [ ".fs"; ".fsx"; ".fsi" ]) files with | [] -> printfn "No changed F# files to format." return (Ok None: Result) | sources -> return Ok(Some(cmd $"dotnet fantomas --json" |> Cmd.args sources)) }) } } command "docs" { description "Build the docs, or serve them with --watch" workingDir repositoryRoot Blocks.restoreTools stage "build" { run "dotnet build -c Release src/Fantomas/Fantomas.fsproj --tl" } Blocks.fsdocs } command "format" { description "Format every source file" workingDir repositoryRoot stage "fantomas" { run $"dotnet fantomas --json {formatTargets}" } } command "repo-config" { description "Point git at the repository's hooks and blame settings" workingDir repositoryRoot stage "git" { run "git config core.hooksPath .githooks" run "git config blame.ignoreRevsFile .git-blame-ignore-revs" run "git config blame.markIgnoredLines true" } } BuildScripts2.commands BuildAnalyzers2.commands BuildRelease2.commands BuildCompiler2.commands } |> exit ``` ::: :::: ::::: :::::tab shared.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/shared.fsx" showLineNumbers #r "../artifacts/bin/Fantomas.FCS/debug/Fantomas.FCS.dll" #r "../artifacts/bin/Fantomas.Core/debug/Fantomas.Core.dll" #r "nuget: editorconfig, 0.15.0" #load "../src/Fantomas/Suggestion.fs" #load "../src/Fantomas/EditorConfig.fs" open System.IO open Fantomas.Core open Fantomas.EditorConfig let parseEditorConfigContent (content: string) : FormatConfig = let tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) Directory.CreateDirectory(tempDir) |> ignore let editorConfigPath = Path.Combine(tempDir, ".editorconfig") let fsharpFile = Path.Combine(tempDir, "temp.fs") File.WriteAllText(editorConfigPath, $"root = true\n\n[*.fs]\n%s{content}") File.WriteAllText(fsharpFile, "") try match tryReadConfiguration fsharpFile with | Some result -> result.Config | None -> FormatConfig.Default finally Directory.Delete(tempDir, true) /// Parses args and returns (source, isSignature, config). /// Accepts either a file path as last arg, or source code via stdin. /// Optional flags: --editorconfig , --signature let parseArgs (args: string array) = let editorConfigIdx = args |> Array.tryFindIndex (fun a -> a = "--editorconfig") let hasSignatureFlag = args |> Array.exists (fun a -> a = "--signature") let defineIdx = args |> Array.tryFindIndex (fun a -> a = "--define") let config = match editorConfigIdx with | Some idx -> parseEditorConfigContent args.[idx + 1] | None -> FormatConfig.Default let defines = match defineIdx with | Some idx -> args.[idx + 1].Split(',') |> Array.toList | None -> [] // Collect flag indices to determine which arg (if any) is the input file let flagIndices = [| match editorConfigIdx with | Some idx -> yield idx yield idx + 1 | None -> () match defineIdx with | Some idx -> yield idx yield idx + 1 | None -> () yield! args |> Array.indexed |> Array.choose (fun (i, a) -> if a = "--signature" then Some i else None) |] let positionalArgs = args |> Array.indexed |> Array.filter (fun (i, _) -> not (Array.contains i flagIndices)) |> Array.map snd match Array.tryLast positionalArgs with | Some path when File.Exists(path) -> let sample = File.ReadAllText(path) let isSignature = hasSignatureFlag || path.EndsWith(".fsi") sample, isSignature, config, defines | _ -> let sample = stdin.ReadToEnd() sample, hasSignatureFlag, config, defines ``` ::: :::tab Ported ```fsharp title="scripts/shared2.fsx" showLineNumbers #r "../artifacts/bin/Fantomas.FCS/debug/Fantomas.FCS.dll" #r "../artifacts/bin/Fantomas.Core/debug/Fantomas.Core.dll" #r "nuget: editorconfig, 0.15.0" #r "nuget: Partas.Build, 0.4.0-alpha.3" #load "../src/Fantomas/Suggestion.fs" #load "../src/Fantomas/EditorConfig.fs" open Partas.Build open System.IO open Fantomas.Core open Fantomas.EditorConfig module Utils = let runIfMain (name: string) (fn: unit -> int): unit = if Args.scriptName() |> ValueOption.exists ((=) name) then fn() |> exit module Options = let editorConfig= Input.optionMaybe "--editorconfig" |> InputSpec.ofInput |> InputSpec.map (function | None -> FormatConfig.Default | Some content -> let tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) Directory.CreateDirectory(tempDir) |> ignore let editorConfigPath = Path.Combine(tempDir, ".editorconfig") let fsharpFile = Path.Combine(tempDir, "temp.fs") File.WriteAllText(editorConfigPath, $"root = true\n\n[*.fs]\n%s{content}") File.WriteAllText(fsharpFile, "") try match tryReadConfiguration fsharpFile with | Some result -> result.Config | None -> FormatConfig.Default finally Directory.Delete(tempDir, true) ) let signature = Input.option "--signature" |> Input.alias "-s" let defines = Input.option "--define" |> Input.alias "-d" let inputPath = Input.argument "input" |> Input.description "input file or content" let inputContent = input { let! inputPath = inputPath and! signature = signature return if File.Exists(inputPath) then {| sample = File.ReadAllText(inputPath) isSignature = signature || inputPath.EndsWith(".fsi") |} else {| sample = inputPath isSignature = signature |} } ``` ::: :::: ::::: :::::tab ast.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/ast.fsx" showLineNumbers #load "shared.fsx" open System.IO open Shared let parseAst (input: string) (isSignature: bool) (defines: string list) = try let ast = Fantomas.FCS.Parse.parseFile isSignature (Fantomas.FCS.Text.SourceText.ofString input) defines |> fst $"%A{ast}" with ex -> $"Error while parsing AST: %A{ex}" match Array.tryHead fsi.CommandLineArgs with | Some scriptPath -> let scriptFile = FileInfo(scriptPath) let sourceFile = FileInfo(Path.Combine(__SOURCE_DIRECTORY__, __SOURCE_FILE__)) if scriptFile.FullName = sourceFile.FullName then let sample, isSignature, _, defines = parseArgs fsi.CommandLineArgs.[1..] parseAst sample isSignature defines |> printfn "%s" | _ -> printfn "Usage: dotnet fsi ast.fsx [--signature] [--define FOO,BAR] " ``` ::: :::tab Ported ```fsharp title="scripts/ast2.fsx" showLineNumbers #load "shared2.fsx" open System.IO open Shared2 open Partas.Build let parseAst = input { let! inputContent = Options.inputContent and! defines = Options.defines return try let ast = Fantomas.FCS.Parse.parseFile inputContent.isSignature (Fantomas.FCS.Text.SourceText.ofString inputContent.sample) defines |> fst $"%A{ast}" with ex -> $"Error while parsing AST: %A{ex}" } Utils.runIfMain "ast2.fsx" <| fun () -> rootCommandOfScript { name "ast2.fsx" description "Parse ast" input { let! parsedAst = parseAst return stage "parsed ast" { echo parsedAst } } } ``` ::: :::: ::::: :::::tab oak.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/oak.fsx" showLineNumbers #load "shared.fsx" open System.IO open Fantomas.Core open Shared let parseOak (input: string) (isSignature: bool) (defines: string list) = async { try let! oaks = CodeFormatter.ParseOakAsync(isSignature, input) let result = if List.isEmpty defines then Array.tryHead oaks else let sortedDefines = List.sort defines oaks |> Array.tryFind (fun (_, d) -> List.sort d = sortedDefines) match result with | None -> return "No Oak found in input" | Some(oak, _) -> return (string oak) with ex -> return $"Error while parsing to Oak: %A{ex}" } match Array.tryHead fsi.CommandLineArgs with | Some scriptPath -> let scriptFile = FileInfo(scriptPath) let sourceFile = FileInfo(Path.Combine(__SOURCE_DIRECTORY__, __SOURCE_FILE__)) if scriptFile.FullName = sourceFile.FullName then let sample, isSignature, _, defines = parseArgs fsi.CommandLineArgs.[1..] parseOak sample isSignature defines |> Async.RunSynchronously |> printfn "%s" | _ -> printfn "Usage: dotnet fsi oak.fsx [--signature] [--define FOO,BAR] " ``` ::: :::tab Ported ```fsharp title="scripts/oak2.fsx" showLineNumbers #load "shared2.fsx" open System.IO open Fantomas.Core open Shared2 open Partas.Build let parseOak = input { let! inputContent = Options.inputContent and! defines = Options.defines let input = inputContent.sample let isSignature = inputContent.isSignature return async { try let! oaks = CodeFormatter.ParseOakAsync(isSignature, input) let result = if List.isEmpty defines then Array.tryHead oaks else let sortedDefines = List.sort defines oaks |> Array.tryFind (fun (_, d) -> List.sort d = sortedDefines) match result with | None -> return "No Oak found in input" | Some(oak, _) -> return (string oak) with ex -> return $"Error while parsing to Oak: %A{ex}" } } Utils.runIfMain "oak2.fsx" <| fun () -> rootCommandOfScript { name "oak2.fsx" input { let! asyncCont = parseOak return stage "oak" { echo (Async.RunSynchronously asyncCont) } } } ``` ::: :::: ::::: :::::tab format.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/format.fsx" showLineNumbers #load "shared.fsx" open System.IO open Fantomas.Core open Shared let format (input: string) (isSignature: bool) (config: FormatConfig) = async { try let! result = CodeFormatter.FormatDocumentAsync(isSignature, input, config) let formattedCode = result.Code // Check for diagnostics in the formatted output let sourceText = Fantomas.FCS.Text.SourceText.ofString formattedCode let _, diagnostics = Fantomas.FCS.Parse.parseFile isSignature sourceText [] for d in diagnostics do eprintfn "Diagnostic: %A %A %s %A" d.Severity d.ErrorNumber d.Message d.Range return formattedCode with ex -> return $"Error while formatting: %A{ex}" } match Array.tryHead fsi.CommandLineArgs with | Some scriptPath -> let scriptFile = FileInfo(scriptPath) let sourceFile = FileInfo(Path.Combine(__SOURCE_DIRECTORY__, __SOURCE_FILE__)) if scriptFile.FullName = sourceFile.FullName then let sample, isSignature, config, _ = parseArgs fsi.CommandLineArgs.[1..] format sample isSignature config |> Async.RunSynchronously |> printfn "%s" | _ -> printfn "Usage: dotnet fsi format.fsx [--editorconfig ] " ``` ::: :::tab Ported ```fsharp title="scripts/format2.fsx" showLineNumbers #load "shared2.fsx" open System.IO open Fantomas.Core open Shared2 open Partas.Build let format = input { let! inputContent = Options.inputContent and! editorConfig = Options.editorConfig let input = inputContent.sample let isSignature = inputContent.isSignature return async { try let! result = CodeFormatter.FormatDocumentAsync(isSignature, input, editorConfig) let formattedCode = result.Code // Check for diagnostics in the formatted output let sourceText = Fantomas.FCS.Text.SourceText.ofString formattedCode let _, diagnostics = Fantomas.FCS.Parse.parseFile isSignature sourceText [] for d in diagnostics do eprintfn "Diagnostic: %A %A %s %A" d.Severity d.ErrorNumber d.Message d.Range return formattedCode with ex -> return $"Error while formatting: %A{ex}" } } Utils.runIfMain "format2.fsx" <| fun () -> rootCommandOfScript { name "format2.fsx" input { let! asyncCont = format return stage "formatted" { echo (Async.RunSynchronously asyncCont) } } } ``` ::: :::: ::::: :::::tab writer-events.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/writer-events.fsx" showLineNumbers #load "shared.fsx" open System.IO open Fantomas.Core open Shared let getWriterEvents (input: string) (isSignature: bool) (config: FormatConfig) (defines: string list) = async { try let! events = CodeFormatter.GetWriterEventsAsync(isSignature, input, config, defines) return events |> Array.map string |> String.concat "\n" with ex -> return $"Error while getting writer events: %A{ex}" } match Array.tryHead fsi.CommandLineArgs with | Some scriptPath -> let scriptFile = FileInfo(scriptPath) let sourceFile = FileInfo(Path.Combine(__SOURCE_DIRECTORY__, __SOURCE_FILE__)) if scriptFile.FullName = sourceFile.FullName then let sample, isSignature, config, defines = parseArgs fsi.CommandLineArgs.[1..] getWriterEvents sample isSignature config defines |> Async.RunSynchronously |> printfn "%s" | _ -> printfn "Usage: dotnet fsi writer-events.fsx [--editorconfig ] [--define FOO,BAR] " ``` ::: :::tab Ported ```fsharp title="scripts/writer-events2.fsx" showLineNumbers #load "shared2.fsx" open System.IO open Fantomas.Core open Shared2 open Partas.Build let getWriterEvents = input { let! inputContent = Options.inputContent and! config = Options.editorConfig and! defines = Options.defines let input = inputContent.sample let isSignature = inputContent.isSignature return async { try let! events = CodeFormatter.GetWriterEventsAsync(isSignature, input, config, defines) return events |> Array.map string |> String.concat "\n" with ex -> return $"Error while getting writer events: %A{ex}" } } Utils.runIfMain "writer-events2.fsx" <| fun () -> rootCommandOfScript { name "writer-events2.fsx" input { let! asyncCont = getWriterEvents return stage "writer events" { echo (Async.RunSynchronously asyncCont) } } } ``` ::: :::: ::::: :::::tab chain.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/chain.fsx" showLineNumbers #load "shared.fsx" open System.IO open Fantomas.Core open Fantomas.Core.SyntaxOak open Shared // Best-effort: extract a short display name from the member expression of a segment. let rec exprName (e: Expr) : string = match e with | Expr.Ident n -> n.Text | Expr.OptVar n -> n.Identifier.Content |> List.choose (function | IdentifierOrDot.Ident i -> Some i.Text | _ -> None) |> String.concat "." | Expr.TypeApp n -> $"{exprName n.Identifier}<...>" | _ -> e.GetType().Name let printChain (chain: ExprChain) = printfn "Chain:" printfn " Head : %s" (exprName chain.Head) printfn " Segments: %d" chain.Segments.Length // When the chain has a terminal call, its method name lives in the LAST segment // (the call itself is the Terminal). Labelling that one "navigation" would be a lie. let hasTerminal = match chain.Terminal with | ChainTerminal.NoTerminal -> false | _ -> true let lastIndex = chain.Segments.Length - 1 chain.Segments |> List.iteri (fun i seg -> match seg with | ChainSegment.DotMember(_, expr) when hasTerminal && i = lastIndex -> printfn " [%02d] terminal .%s (called below)" i (exprName expr) | ChainSegment.DotMember(_, expr) -> printfn " [%02d] navigation .%s" i (exprName expr) | ChainSegment.DotApplication(_, expr, ChainCall.Unit _) -> printfn " [%02d] action .%s()" i (exprName expr) | ChainSegment.DotApplication(_, expr, ChainCall.Paren _) -> printfn " [%02d] action .%s(...)" i (exprName expr) | ChainSegment.DotIndex(_, idx) -> printfn " [%02d] navigation .[%s]" i (exprName idx)) let terminalStr = match chain.Terminal with | ChainTerminal.NoTerminal -> "(none)" | ChainTerminal.SpaceAllowed(ChainCall.Unit _) -> "SpaceAllowed ()" | ChainTerminal.SpaceAllowed(ChainCall.Paren _) -> "SpaceAllowed (...)" | ChainTerminal.NoSpaceAllowed(ChainCall.Unit _) -> "NoSpaceAllowed ()" | ChainTerminal.NoSpaceAllowed(ChainCall.Paren _) -> "NoSpaceAllowed (...)" printfn " Terminal: %s" terminalStr // Recursively collect all ExprChain nodes in the Oak's Node tree. let rec collectChains (node: Node) : ExprChain list = [ match node with | :? ExprChain as chain -> yield chain | _ -> () for child in node.Children do yield! collectChains child ] match Array.tryHead fsi.CommandLineArgs with | Some scriptPath -> let scriptFile = FileInfo(scriptPath) let sourceFile = FileInfo(Path.Combine(__SOURCE_DIRECTORY__, __SOURCE_FILE__)) if scriptFile.FullName = sourceFile.FullName then let source, isSignature, _, _ = parseArgs fsi.CommandLineArgs.[1..] let oak = CodeFormatter.ParseOakAsync(isSignature, source) |> Async.RunSynchronously |> Array.head |> fst let chains = collectChains oak if chains.IsEmpty then printfn "No chain expression found in input." else printfn "Found %d chain(s):\n" chains.Length chains |> List.iteri (fun i chain -> printfn "--- Chain #%d ---" (i + 1) printChain chain printfn "") | _ -> printfn "Usage: dotnet fsi chain.fsx [--signature] []" printfn " source code is read from stdin when no input file is given" ``` ::: :::tab Ported ```fsharp title="scripts/chain2.fsx" showLineNumbers #load "shared2.fsx" open System.IO open Fantomas.Core open Fantomas.Core.SyntaxOak open Shared2 // Best-effort: extract a short display name from the member expression of a segment. let rec exprName (e: Expr) : string = match e with | Expr.Ident n -> n.Text | Expr.OptVar n -> n.Identifier.Content |> List.choose (function | IdentifierOrDot.Ident i -> Some i.Text | _ -> None) |> String.concat "." | Expr.TypeApp n -> $"{exprName n.Identifier}<...>" | _ -> e.GetType().Name let printChain (chain: ExprChain) = printfn "Chain:" printfn " Head : %s" (exprName chain.Head) printfn " Segments: %d" chain.Segments.Length // When the chain has a terminal call, its method name lives in the LAST segment // (the call itself is the Terminal). Labelling that one "navigation" would be a lie. let hasTerminal = match chain.Terminal with | ChainTerminal.NoTerminal -> false | _ -> true let lastIndex = chain.Segments.Length - 1 chain.Segments |> List.iteri (fun i seg -> match seg with | ChainSegment.DotMember(_, expr) when hasTerminal && i = lastIndex -> printfn " [%02d] terminal .%s (called below)" i (exprName expr) | ChainSegment.DotMember(_, expr) -> printfn " [%02d] navigation .%s" i (exprName expr) | ChainSegment.DotApplication(_, expr, ChainCall.Unit _) -> printfn " [%02d] action .%s()" i (exprName expr) | ChainSegment.DotApplication(_, expr, ChainCall.Paren _) -> printfn " [%02d] action .%s(...)" i (exprName expr) | ChainSegment.DotIndex(_, idx) -> printfn " [%02d] navigation .[%s]" i (exprName idx)) let terminalStr = match chain.Terminal with | ChainTerminal.NoTerminal -> "(none)" | ChainTerminal.SpaceAllowed(ChainCall.Unit _) -> "SpaceAllowed ()" | ChainTerminal.SpaceAllowed(ChainCall.Paren _) -> "SpaceAllowed (...)" | ChainTerminal.NoSpaceAllowed(ChainCall.Unit _) -> "NoSpaceAllowed ()" | ChainTerminal.NoSpaceAllowed(ChainCall.Paren _) -> "NoSpaceAllowed (...)" printfn " Terminal: %s" terminalStr // Recursively collect all ExprChain nodes in the Oak's Node tree. let rec collectChains (node: Node) : ExprChain list = [ match node with | :? ExprChain as chain -> yield chain | _ -> () for child in node.Children do yield! collectChains child ] open Partas.Build Utils.runIfMain "chain2.fsx" <| fun _ -> rootCommandOfScript { name "chain2.fsx" input { let! inputContent = Options.inputContent return stage "chain" { run (async { let! oak = CodeFormatter.ParseOakAsync(inputContent.isSignature, inputContent.sample) let oak = oak |> Array.head |> fst let chains = collectChains oak if chains.IsEmpty then printfn "No chain expression found in input." else printfn "Found %d chain(s):\n" chains.Length chains |> List.iteri (fun i chain -> printfn "--- Chain #%d ---" (i + 1) printChain chain printfn "") }) } } } ``` ::: :::: ::::: :::::tab BuildCommon.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/BuildCommon.fsx" showLineNumbers #r "nuget: CliWrap, 3.6.4" open System open System.IO open CliWrap open CliWrap.Buffered // This file is loaded by `build.fsx`. It defines things and runs nothing, so a direct run would // look like a success while doing no work at all. Say so instead. if Path.GetFileName(fsi.CommandLineArgs[0]) = Path.GetFileName __SOURCE_FILE__ then eprintfn "%s is loaded by build.fsx and is not meant to be run on its own." (Path.GetFileName __SOURCE_FILE__) eprintfn "Run a pipeline instead, for example: dotnet fsi build.fsx -- -p Build" exit 1 // What every part of the build agrees on: where things are, how to run a process, and what the // working tree changed. // // Anything here is used by at least two of `build.fsx`, `BuildAnalyzers.fsx`, `BuildRelease.fsx` and // `BuildCompiler.fsx`. Anything used by only one of them belongs in that one. let () a b = Path.Combine(a, b) /// The repository root. /// /// `__SOURCE_DIRECTORY__` is the folder of the file it is written in, which here is `scripts/` and /// not the root. Every path below is anchored to this instead, so that what the build points at does /// not depend on which script did the loading. let repositoryRoot: string = Path.GetFullPath(__SOURCE_DIRECTORY__ "..") let artifactsDir: string = repositoryRoot "artifacts" let binDir: string = artifactsDir "bin" let packagesDir: string = artifactsDir "package" "release" let coverageReportDir: string = repositoryRoot "coveragereport" /// Where the analyzers write their report per project, before the reports are merged into one. let analysisReportsDir: string = repositoryRoot "analysisreports" /// The merged analyzer report. Holds the last run and nothing more. let mergedAnalysisReport: string = repositoryRoot "analysis.sarif" /// Deleting a folder can fail with "Directory not empty" when something writes into it while /// the delete is walking it, Finder dropping a .DS_Store back in is enough. The delete does /// remove what it got to, so retry a couple of times before giving up. let rec private deleteDirectory (attempt: int) (dir: string) : Async = async { try Directory.Delete(dir, true) with :? IOException when attempt < 5 -> do! Async.Sleep(100 * attempt) if Directory.Exists(dir) then return! deleteDirectory (attempt + 1) dir } let cleanFolders (input: string seq) : Async = async { for dir in input do if Directory.Exists(dir) then do! deleteDirectory 1 dir } let runGitCommand (arguments: string) = async { let! result = Cli.Wrap("git").WithArguments(arguments).WithWorkingDirectory(repositoryRoot).ExecuteBufferedAsync().Task |> Async.AwaitTask return result.ExitCode, result.StandardOutput, result.StandardError } /// The files git reports as changed in the working tree, as paths relative to the repository root. /// /// The porcelain format is two status columns, a space, and then the path, so the path starts at /// the fourth character. A rename reads as `old -> new`, of which only the new path still exists. /// Deleted files are dropped: there is nothing left to look at. /// /// Untracked files are asked for one by one. Git otherwise reports a new folder as a single entry /// and the files inside it are never named, which is exactly the case of a feature that arrives as /// a new folder of sources. let changedFiles () : Async = async { let! exitCode, stdout, stdErr = runGitCommand "status --porcelain --untracked-files=all" if exitCode <> 0 then failwith $"Could not read the git status.\n{stdErr}" return stdout.Split('\n') |> Array.choose (fun (line: string) -> let line: string = line.TrimEnd('\r') if line.Length < 4 || line[0] = 'D' || line[1] = 'D' then None else let path: string = line.Substring 3 let path: string = match path.IndexOf(" -> ", StringComparison.Ordinal) with | -1 -> path | arrow -> path.Substring(arrow + 4) Some(path.Trim('"').Replace('\\', '/'))) |> List.ofArray } let hasExtension (extensions: string list) (path: string) : bool = extensions |> List.exists (fun (extension: string) -> path.EndsWith(extension, StringComparison.Ordinal)) /// How much of a file the working tree touched. type ChangedLines = /// Every line, which is what a file that git has never seen amounts to. | WholeFile /// The lines a diff hunk added or altered. | Lines of Set /// The lines the working tree changed, per file, keyed by repository relative path. /// /// `git diff HEAD` covers staged and unstaged changes alike, and `-U0` asks for no context lines, /// so every hunk header names exactly the lines that differ. An untracked file has no diff to read /// and is new in its entirety. let changedLines () : Async> = async { let! files = changedFiles () let! exitCode, stdout, stdErr = runGitCommand "diff -U0 HEAD --" if exitCode <> 0 then failwith $"Could not read the git diff.\n{stdErr}" let hunk: Text.RegularExpressions.Regex = Text.RegularExpressions.Regex(@"^@@ -\S+ \+(?\d+)(,(?\d+))? @@") let mutable scopes: Map = Map.empty let mutable current: string option = None for line in stdout.Split('\n') do let line: string = line.TrimEnd('\r') if line.StartsWith("+++ b/", StringComparison.Ordinal) then current <- Some(line.Substring 6) elif line.StartsWith("+++ ", StringComparison.Ordinal) then current <- None else let m: Text.RegularExpressions.Match = hunk.Match line match current with | None -> () | Some file when m.Success -> let start: int = int m.Groups["start"].Value let count: int = if m.Groups["count"].Success then int m.Groups["count"].Value else 1 // A pure deletion reports a count of zero. Nothing of it survives to report on. let added: Set = set [ start .. start + count - 1 ] let merged: ChangedLines = match Map.tryFind file scopes with | Some(Lines existing) -> Lines(Set.union existing added) | _ -> Lines added scopes <- Map.add file merged scopes | Some _ -> () // Anything git named as changed but has no diff hunk is untracked, so all of it is new. for file in files do if not (Map.containsKey file scopes) then scopes <- Map.add file WholeFile scopes return scopes } /// How much of the file a finding sits in the working tree touched, or `None` when it touched none /// of it. /// /// `changedLines` puts an entry in the map for every file git named, so a miss here is a fact and /// not an absence of information: git was asked, and said this file did not change. let scopeFor (scopes: Map) (path: string) : ChangedLines option = let normalized: string = path.Replace('\\', '/') scopes |> Map.tryPick (fun (file: string) (scope: ChangedLines) -> if normalized.EndsWith(file, StringComparison.Ordinal) then Some scope else None) ``` ::: :::tab Ported ```fsharp title="scripts/BuildCommon2.fsx" showLineNumbers #r "nuget: Partas.Build, 0.4.0-alpha.3" open System open System.Diagnostics open System.IO open Partas.Build // What every part of the build agrees on: where things are, how a process is run, what the working // tree changed, and the CLI options and stages more than one command uses. let () a b = Path.Combine(a, b) /// The repository root. Every path below is anchored here rather than at `__SOURCE_DIRECTORY__`. let repositoryRoot: string = Path.GetFullPath(__SOURCE_DIRECTORY__ "..") let artifactsDir: string = repositoryRoot "artifacts" let binDir: string = artifactsDir "bin" let packagesDir: string = artifactsDir "package" "release" let coverageReportDir: string = repositoryRoot "coveragereport" /// Where the analyzers write a report per project, before the reports are merged into one. let analysisReportsDir: string = repositoryRoot "analysisreports" /// The merged analyzer report. Holds the last run and nothing more. let mergedAnalysisReport: string = repositoryRoot "analysis.sarif" /// Runs `fn` only when `name` is the script `dotnet fsi` was started with, so a file that is /// `#load`ed is a library and the same file run directly is a CLI. let runIfMain (name: string) (fn: unit -> int) : unit = if Args.scriptName () |> ValueOption.exists ((=) name) then fn () |> exit /// Running a `Cmd` outside a stage, for the places that need its output as a value. module Proc = let private startInfo (cmd: Cmd) : ProcessStartInfo = let info = ProcessStartInfo( cmd.Executable, UseShellExecute = false, WorkingDirectory = repositoryRoot, RedirectStandardOutput = true, RedirectStandardError = true ) for arg in cmd.Arguments do info.ArgumentList.Add arg info /// Runs to completion and returns the exit code with both output streams, held back whole. let buffered (cmd: Cmd) : Async = async { use proc = Process.Start(startInfo cmd) let stdout = proc.StandardOutput.ReadToEndAsync() let stderr = proc.StandardError.ReadToEndAsync() do! proc.WaitForExitAsync() |> Async.AwaitTask let! out = Async.AwaitTask stdout let! err = Async.AwaitTask stderr return proc.ExitCode, out, err } /// Runs to completion, printing the command and forwarding its output line by line. let stream (cmd: Cmd) : Async = async { printfn "$ %s" (Cmd.toLogString cmd) use proc = new Process(StartInfo = startInfo cmd) proc.OutputDataReceived.Add(fun e -> if not (isNull e.Data) then printfn "%s" e.Data) proc.ErrorDataReceived.Add(fun e -> if not (isNull e.Data) then eprintfn "%s" e.Data) proc.Start() |> ignore proc.BeginOutputReadLine() proc.BeginErrorReadLine() do! proc.WaitForExitAsync() |> Async.AwaitTask return proc.ExitCode } /// Deletes a folder, retrying while something else still holds a file in it. let rec private deleteDirectory (attempt: int) (dir: string) : Async = async { try Directory.Delete(dir, true) with :? IOException when attempt < 5 -> do! Async.Sleep(100 * attempt) if Directory.Exists dir then return! deleteDirectory (attempt + 1) dir } let cleanFolders (input: string seq) : Async = async { for dir in input do if Directory.Exists dir then do! deleteDirectory 1 dir } let runGitCommand (arguments: string) : Async = Proc.buffered (Cmd.create "git" arguments) /// The files git reports as changed in the working tree, as paths relative to the repository root. /// Deleted files are dropped; a rename contributes only its new path. let changedFiles () : Async = async { let! exitCode, stdout, stdErr = runGitCommand "status --porcelain --untracked-files=all" if exitCode <> 0 then failwith $"Could not read git status.\n{stdErr}" return stdout.Split('\n') |> Array.choose (fun (line: string) -> let line: string = line.TrimEnd('\r') if line.Length < 4 || line[0] = 'D' || line[1] = 'D' then None else let path: string = line.Substring 3 let path: string = match path.IndexOf(" -> ", StringComparison.Ordinal) with | -1 -> path | arrow -> path.Substring(arrow + 4) Some(path.Trim('"').Replace('\\', '/'))) |> List.ofArray } let hasExtension (extensions: string list) (path: string) : bool = extensions |> List.exists (fun (extension: string) -> path.EndsWith(extension, StringComparison.Ordinal)) /// How much of a file the working tree touched. type ChangedLines = | Lines of Set | WholeFile /// The changed lines of every changed file. An untracked file has no diff and is new in its entirety. let changedLines () : Async> = async { let! files = changedFiles () let! exitCode, stdout, stdErr = runGitCommand "diff -U0 HEAD --" if exitCode <> 0 then failwith $"Could not diff.\n{stdErr}" let hunk = Text.RegularExpressions.Regex(@"^@@ -\S+ \+(?\d+)(,(?\d+))? @@") let mutable scopes: Map = Map.empty let mutable current: string option = None for line in stdout.Split('\n') do let line: string = line.TrimEnd('\r') if line.StartsWith("+++ b/", StringComparison.Ordinal) then current <- Some(line.Substring 6) elif line.StartsWith("+++ ", StringComparison.Ordinal) then current <- None else let m = hunk.Match line match current with | Some file when m.Success -> let start: int = int m.Groups["start"].Value let count: int = if m.Groups["count"].Success then int m.Groups["count"].Value else 1 let added: Set = set [ start .. start + count - 1 ] let merged: ChangedLines = match Map.tryFind file scopes with | Some(Lines existing) -> Lines(Set.union existing added) | _ -> Lines added scopes <- Map.add file merged scopes | _ -> () for file in files do if not (Map.containsKey file scopes) then scopes <- Map.add file WholeFile scopes return scopes } /// The scope of a path as the analyzers report it, which may be absolute or root-relative. let scopeFor (scopes: Map) (path: string) : ChangedLines option = let normalized: string = path.Replace('\\', '/') scopes |> Map.tryPick (fun (file: string) (scope: ChangedLines) -> if normalized = file || normalized.EndsWith("/" + file, StringComparison.Ordinal) then Some scope else None) /// The CLI options more than one command reads. A stage binds one in an `input { }` block, and that /// is what puts the option in the command's `--help`. module Options = let quick = Input.option "--quick" |> Input.alias "-q" |> Input.desc "Skip tool restore and clean" let skipTests = Input.option "--skip-tests" |> Input.desc "Skip the unit tests" let ci = Baked.Input.CI.isCI let config = Input.option "--configuration" |> Input.alias "-c" |> Input.def "Release" |> Input.acceptOnlyFromAmong [ "Debug"; "Release" ] let dryRun = Input.option "--dry-run" |> Input.desc "Print what would be pushed or created, without doing it" let nugetKey = Input.optionMaybe "--nuget-key" |> Input.desc "NuGet API key; defaults to NUGET_KEY" |> Input.def ( Environment.GetEnvironmentVariable "NUGET_KEY" |> Option.ofObj |> Option.filter (String.IsNullOrWhiteSpace >> not) ) /// The stages every command opens with. module Blocks = let restoreTools = input { let! quick = Options.quick return stage "restore tools" { when' (not quick) run "dotnet tool restore" } } let restoreSolution = input { let! quick = Options.quick return stage "restore solution" { when' (not quick) run "dotnet restore --tl" } } let clean (folders: string list) = input { let! quick = Options.quick return stage "clean" { when' (not quick) run (cleanFolders folders) } } let build = input { let! config = Options.config return stage "build" { run (cmd $"dotnet build -c {config} --tl") } } let test = input { let! config = Options.config and! skip = Options.skipTests return stage "unit tests" { when' (not skip) run (cmd $"dotnet test -c {config} --tl") } } let pack = input { let! config = Options.config return stage "pack" { run (cmd $"dotnet pack --no-restore -c {config} --tl") } } ``` ::: :::: ::::: :::::tab BuildScripts.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/BuildScripts.fsx" showLineNumbers #r "nuget: CliWrap, 3.6.4" open System.IO open System.Text.RegularExpressions open CliWrap open CliWrap.Buffered // Loaded by `build.fsx`, after `BuildCommon.fsx`. An error here saying BuildCommon is not defined // means this file was run on its own; it is a library, so run a pipeline from build.fsx instead. open BuildCommon // Compiling this repository's own scripts without running them. // // Nothing else in the build looks at them, so a rename in `src/` that one of them refers to breaks // it silently: the script keeps sitting there and fails the next time somebody reaches for it, // which is usually in the middle of something else. The documentation scripts have exactly that // problem too, and both of the ones that `#load` a file out of `src/` were broken this way. // // They fall into two groups, and the difference is which build they reference: the scripts beside // this file take the debug build, the documentation takes the release one. That is why there are // two entry points here rather than one, run from two different points of the pipeline. /// The project the diagnostic scripts are compiled against. /// /// Anchored at `repositoryRoot` rather than written relative, so it names the same project whatever /// the working directory of the run is. /// /// `shared.fsx` references the debug build of Fantomas.Core, and Fantomas.Core references /// Fantomas.FCS, so building this one project puts both assemblies where the scripts look for them. /// The CLI and the test projects are no part of what a script loads and are not built for this. /// /// It is the debug build they reference, and that is not a detail to tidy away into the release /// build the rest of the pipeline makes: these scripts are for prototyping against a local /// Fantomas, which is something you want to be able to step through. let scriptProject: string = repositoryRoot "src" "Fantomas.Core" "Fantomas.Core.fsproj" /// Of the given scripts, the ones that are meant to be run directly. /// /// A script that another of them `#load`s is left out, because it is already compiled as part of /// whatever loads it. Several cannot be compiled alone at all, by design: this file and its /// neighbours expect `BuildCommon.fsx` to be in scope, which is only true when `build.fsx` did the /// loading. Reading the `#load` lines rather than listing those exceptions means a script added /// later is checked without anything here having to be edited. let private runnableIn (scripts: string list) : string list = let loadDirective: Regex = Regex("^\\s*#load\\s+\"([^\"]+)\"") let loaded: Set = scripts |> Seq.collect (fun (script: string) -> let folder: string = Path.GetDirectoryName script File.ReadLines script |> Seq.choose (fun (line: string) -> let matched: Match = loadDirective.Match line if matched.Success then Some(Path.GetFullPath(folder matched.Groups[1].Value)) else None)) |> Set.ofSeq scripts |> List.filter (fun (script: string) -> not (loaded.Contains(Path.GetFullPath script))) /// `build.fsx` and the diagnostic scripts beside this file, which reference the debug build. let runnableScripts () : string list = [ repositoryRoot "build.fsx" yield! Directory.EnumerateFiles(repositoryRoot "scripts", "*.fsx") ] |> runnableIn /// The documentation scripts, which fsdocs turns into the pages of the site. They reference the /// release build, so they can only be compiled once the pipeline has made one. let runnableDocScripts () : string list = Directory.EnumerateFiles(repositoryRoot "docs", "*.fsx", SearchOption.AllDirectories) |> List.ofSeq |> runnableIn /// Compile one script and stop short of running it, reporting whatever the compiler said. let private typecheckScript (script: string) : Async = async { let! result = Cli .Wrap("dotnet") .WithArguments($"fsi --typecheck-only --nologo \"{script}\"") .WithWorkingDirectory(repositoryRoot) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync() .Task |> Async.AwaitTask return script, result.ExitCode, (result.StandardOutput + result.StandardError).Trim() } /// Compile each of the given scripts, and report what the compiler said about any that would not /// compile. Writes nothing: no script is run, and the assemblies they reference are built by the /// stage before whichever one calls this. let private check (scripts: string list) : Async = async { // One at a time: the compiler output of a script that fails is the point of this, and // running them together interleaves it beyond reading. let! results = scripts |> List.map typecheckScript |> Async.Sequential for (script: string), (exitCode: int), (output: string) in results do let name: string = Path.GetRelativePath(repositoryRoot, script) if exitCode = 0 then printfn "%s compiles." name else printfn "%s does not compile:" name printfn "%s" output let failed: int = results |> Array.filter (fun (_, exitCode, _) -> exitCode <> 0) |> Array.length return (if failed = 0 then 0 else 1) } /// Compile `build.fsx` and the diagnostic scripts. Needs the debug build. let checkScripts _ : Async = check (runnableScripts ()) /// Compile the documentation scripts. Needs the release build. let checkDocScripts _ : Async = check (runnableDocScripts ()) ``` ::: :::tab Ported ```fsharp title="scripts/BuildScripts2.fsx" showLineNumbers #load "BuildCommon2.fsx" open System.IO open System.Text.RegularExpressions open Partas.Build open BuildCommon2 // Compiling the repository's own scripts without running them, so a rename in `src/` that one of // them refers to breaks the build rather than the next person who reaches for the script. /// The project the diagnostic scripts are compiled against. `shared.fsx` references the debug build /// of Fantomas.Core, which references Fantomas.FCS, so building this one project places both. let scriptProject: string = repositoryRoot "src" "Fantomas.Core" "Fantomas.Core.fsproj" /// Of the given scripts, the ones meant to be run directly: a script another one `#load`s is /// compiled as part of the loader and is left out. let private runnableIn (scripts: string list) : string list = let loadDirective: Regex = Regex("^\\s*#load\\s+\"([^\"]+)\"") let loaded: Set = scripts |> Seq.collect (fun (script: string) -> let folder: string = Path.GetDirectoryName script File.ReadLines script |> Seq.choose (fun (line: string) -> let matched: Match = loadDirective.Match line if matched.Success then Some(Path.GetFullPath(folder matched.Groups[1].Value)) else None)) |> Set.ofSeq scripts |> List.filter (fun (script: string) -> not (loaded.Contains(Path.GetFullPath script))) /// Both build scripts and the diagnostic scripts beside this file. They reference the debug build. let runnableScripts () : string list = [ repositoryRoot "build.fsx" repositoryRoot "build2.fsx" yield! Directory.EnumerateFiles(repositoryRoot "scripts", "*.fsx") ] |> runnableIn /// The documentation scripts fsdocs turns into pages. They reference the release build. let runnableDocScripts () : string list = Directory.EnumerateFiles(repositoryRoot "docs", "*.fsx", SearchOption.AllDirectories) |> List.ofSeq |> runnableIn /// Compiles one script and stops short of running it, reporting what the compiler said. let private typecheckScript (script: string) : Async = async { let! exitCode, stdout, stderr = Proc.buffered (cmd $"dotnet fsi --typecheck-only --nologo {script}") return script, exitCode, (stdout + stderr).Trim() } /// Compiles the scripts one at a time, so the compiler output of one that fails stays in one piece. let private check (scripts: string list) : Async = async { let! results = scripts |> List.map typecheckScript |> Async.Sequential for (script: string), (exitCode: int), (output: string) in results do let name: string = Path.GetRelativePath(repositoryRoot, script) if exitCode = 0 then printfn "%s compiles." name else printfn "%s does not compile:" name printfn "%s" output let failed: int = results |> Array.filter (fun (_, exitCode, _) -> exitCode <> 0) |> Array.length return (if failed = 0 then 0 else 1) } /// Compiles the build and diagnostic scripts. Needs the debug build. let checkScripts = stage "check scripts" { run (fun _ -> check (runnableScripts ())) } /// Compiles the documentation scripts. Needs the release build. let checkDocScripts = stage "check doc scripts" { run (fun _ -> check (runnableDocScripts ())) } let commands = [ command "check-scripts" { description "Compile the build and diagnostic scripts against a debug build of Fantomas.Core" workingDir repositoryRoot stage "build debug" { run (cmd $"dotnet build {scriptProject} --tl") } checkScripts } command "check-doc-scripts" { description "Compile the documentation scripts against a release build" workingDir repositoryRoot Blocks.build checkDocScripts } ] runIfMain "BuildScripts2.fsx" (fun () -> rootCommandOfScript { name "BuildScripts2.fsx" commands }) ``` ::: :::: ::::: :::::tab BuildAnalyzers.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/BuildAnalyzers.fsx" showLineNumbers #r "nuget: CliWrap, 3.6.4" #r "nuget: FSharp.Data, 6.3.0" open System open System.IO open System.Xml.Linq open System.Xml.XPath open CliWrap open CliWrap.Buffered open FSharp.Data // Loaded by `build.fsx`, after `BuildCommon.fsx`. An error here saying BuildCommon is not defined // means this file was run on its own; it is a library, so run a pipeline from build.fsx instead. open BuildCommon // Running the analyzers, and deciding which of their findings a run set out to report. // // The pipelines reach all of this through a handful of names: `projectsToAnalyze`, `targetsFor`, // `analyzeTargets` and the two filters. Everything between those and the SARIF on disk is detail, // and detail that grew every time the reporting was made more honest. /// The projects the analyzers run over: every project in the solution, minus the ones whose source /// is not ours to change. Fantomas.FCS is generated from the vendored compiler sources, and /// Fantomas.FCS.BuildTasks compiles a single vendored compiler file, so a finding in either is /// something to report upstream rather than something to fix here. Reading the solution rather than /// globbing keeps the rest of the build tooling out. /// /// This includes the analyzers themselves, which are in the solution like everything else. There is /// nothing circular about a rule reporting on the project that defines it: the pipelines build the /// analyzers before running them, so what looks at this code is the build the run started with. let projectsToAnalyze: string list = let excluded = set [ "Fantomas.FCS" ] // Analyzing a project costs roughly what type checking it costs, so the largest one decides how // long the whole run takes. Starting with it means it is never the one left waiting for a slot. let sourceSize (project: string) = Directory.EnumerateFiles(Path.GetDirectoryName(repositoryRoot project), "*.fs", SearchOption.AllDirectories) |> Seq.sumBy (fun file -> FileInfo(file).Length) XDocument.Load(repositoryRoot "fantomas.slnx").XPathSelectElements("//Project") |> Seq.map (fun project -> project.Attribute(XName.Get "Path").Value.Replace('\\', '/')) |> Seq.filter (fun path -> not (excluded.Contains(Path.GetFileNameWithoutExtension path))) |> Seq.sortByDescending sourceSize |> Seq.toList /// One project to hand to the analyzers, and which of its files to look at. /// /// `Files` holds absolute paths, because that is the only form `--include-files` matches: give it a /// path relative to the repository root and it matches nothing, says nothing about it and reports a /// clean project. An empty list asks for every file of the project. type AnalysisTarget = { Project: string; Files: string list } /// What to analyze for a set of changed files: every project that owns one, along with the files of /// its own that changed. A project owns everything under its own folder, which is how every project /// of this solution is laid out. The order is the one `projectsToAnalyze` puts them in. /// /// Only compiled sources and project files count. A script, a document or a test data file is not /// part of any compilation, so changing one leaves the analyzers with nothing new to say. /// /// A changed project file asks for the whole project: what it compiles is no longer what it /// compiled before, and there is no single source file that stands for that. let targetsFor (files: string list) : AnalysisTarget list = let sources: string list = List.filter (hasExtension [ ".fs"; ".fsi" ]) files let projectFiles: string list = List.filter (hasExtension [ ".fsproj" ]) files projectsToAnalyze |> List.choose (fun (project: string) -> let folder: string = project.Substring(0, project.LastIndexOf '/' + 1) let owns (file: string) : bool = file.StartsWith(folder, StringComparison.Ordinal) if List.exists owns projectFiles then Some { Project = project; Files = [] } else match List.filter owns sources with | [] -> None | owned -> Some { Project = project Files = List.map (fun (file: string) -> repositoryRoot file) owned }) /// Where the analyzer project this repository owns is built to. It is deliberately outside the /// solution and does not inherit the root `Directory.Build.props`, so this is an ordinary /// `bin` folder rather than anything under `artifacts`. /// /// `--analyzers-path` is handed this folder rather than `analyzers`, because the SDK searches /// recursively for `*Analyzer*.dll` and would otherwise also find `Fantomas.Analyzers.Tests.dll`. let localAnalyzerPath: string = repositoryRoot "analyzers" "Fantomas.Analyzers" "bin" "Release" "net8.0" /// The analyzers are in the solution, so `Build` compiles and tests them along with everything /// else. The `Analyze` pipelines do not depend on `Build` having run, so they build them again, /// which is cheap and means editing a rule and rerunning the analysis is a single command. let buildLocalAnalyzers: string = "dotnet build analyzers/Fantomas.Analyzers -c Release --tl" /// Where the analyzers live on disk. The two packages are ordinary package references, so MSBuild /// already knows the restored path of each and there is no second place to keep the version in /// sync. The third is ours, and is built by the pipeline that is about to use it. let analyzerPaths () : Async = async { if not (File.Exists(localAnalyzerPath "Fantomas.Analyzers.dll")) then failwith $"The local analyzers are not built. Expected an assembly in {localAnalyzerPath}.\nRun `dotnet build analyzers/Fantomas.Analyzers -c Release` first." let! result = Cli .Wrap("dotnet") .WithArguments( "msbuild src/Fantomas/Fantomas.fsproj -getProperty:PkgIonide_Analyzers " + "-getProperty:PkgG-Research_FSharp_Analyzers" ) .WithWorkingDirectory(repositoryRoot) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync() .Task |> Async.AwaitTask if result.ExitCode <> 0 then failwith $"Could not resolve the analyzer packages. Run `dotnet restore` first.\n{result.StandardError}" let properties = JsonValue.Parse(result.StandardOutput).GetProperty("Properties") return [ for property in properties.Properties() do let name, value = property match value.AsString() with | "" -> failwith $"MSBuild has no value for {name}. Run `dotnet restore` first." | path -> path "analyzers" "dotnet" "fs" localAnalyzerPath ] } /// The number of results a single analyzer report holds, used to report what a project turned up /// the moment it finishes. let sarifResultCount (report: string) : int = if not (File.Exists report) then 0 else JsonValue.Parse(File.ReadAllText report).GetProperty("runs").AsArray() |> Array.sumBy (fun run -> match run.TryGetProperty "results" with | Some results -> results.AsArray().Length | None -> 0) /// Folds the per-project reports into the one SARIF run that GitHub code scanning takes. /// /// SARIF carries a run per tool invocation, but code scanning rejects a file holding several unless /// each names its own category, and one project of this solution is not an analysis of its own. The /// runs all come from the same tool, so their results concatenate into a single run. Every /// invocation is kept, which is what records that a project was looked at even when it turned up /// nothing. /// /// The reports carry no rule metadata, only a `ruleId` per result, so there is no rule table to /// renumber against. Should a later version of the analyzers SDK start writing one, this has to /// merge that too. /// The same record with one property replaced, leaving every other property where it was. let withProperty (name: string) (value: JsonValue) (record: JsonValue) : JsonValue = JsonValue.Record [| for existing, current in record.Properties() -> if existing = name then existing, value else existing, current |] /// A run's `tool.driver.rules`, which is empty when it has none. let rulesOf (run: JsonValue) : JsonValue array = run.TryGetProperty "tool" |> Option.bind (fun (tool: JsonValue) -> tool.TryGetProperty "driver") |> Option.bind (fun (driver: JsonValue) -> driver.TryGetProperty "rules") |> Option.map (fun (rules: JsonValue) -> rules.AsArray()) |> Option.defaultValue [||] /// The same run carrying these rules instead. let withRules (rules: JsonValue array) (run: JsonValue) : JsonValue = match run.TryGetProperty "tool" with | None -> run | Some tool -> match tool.TryGetProperty "driver" with | None -> run | Some driver -> let driver: JsonValue = withProperty "rules" (JsonValue.Array rules) driver withProperty "tool" (withProperty "driver" driver tool) run let mergeSarifReports (reports: string list) (target: string) : unit = let documents = reports |> List.filter File.Exists |> List.map (fun report -> JsonValue.Parse(File.ReadAllText report)) let runs = documents |> List.collect (fun document -> document.GetProperty("runs").AsArray() |> List.ofArray) match documents, runs with | firstDocument :: _, firstRun :: _ -> let concat (name: string) = runs |> List.collect (fun run -> match run.TryGetProperty name with | Some array -> List.ofArray (array.AsArray()) | None -> []) |> Array.ofList |> JsonValue.Array // `ruleIndex` addresses `tool.driver.rules` by position within its own run, so merging the // runs means pointing every result at where its own rule ended up. Keeping the first run's // rules and every run's results, as this used to, left every run after the first pointing // into an array it was never numbered against. // // Identical entries collapse. The analyzers write one entry per finding rather than one per // rule, its `name` being that finding's message, so the same entry is written again for // every finding that reads the same: two bindings called `filename` with no annotation // produce the same id and the same message, in one project or in two. GitHub refuses to // ingest a document whose rules array holds a duplicate, and it is the merged document that // is uploaded. let rules, results = let merged: ResizeArray = ResizeArray() let seen: Collections.Generic.Dictionary = Collections.Generic.Dictionary() let indexOf (rule: JsonValue) : int = let key: string = rule.ToString() match seen.TryGetValue key with | true, index -> index | false, _ -> let index: int = merged.Count merged.Add rule seen[key] <- index index let results: JsonValue list = runs |> List.collect (fun (run: JsonValue) -> // Every rule of the run is placed, whether a result points at it or not, so that // this says the same as before about what the tool knows. let placed: int array = Array.map indexOf (rulesOf run) let repoint (result: JsonValue) : JsonValue = match result.TryGetProperty "ruleIndex" with | None -> result | Some index -> let original: int = index.AsInteger() if original >= 0 && original < placed.Length then withProperty "ruleIndex" (JsonValue.Number(decimal placed[original])) result else result match run.TryGetProperty "results" with | None -> [] | Some results -> results.AsArray() |> Array.map repoint |> List.ofArray) List.ofSeq merged, results let merged = JsonValue.Record [| "$schema", firstDocument.GetProperty("$schema") "version", firstDocument.GetProperty("version") "runs", JsonValue.Array [| JsonValue.Record [| "tool", (withRules (Array.ofList rules) firstRun).GetProperty("tool") "columnKind", firstRun.GetProperty("columnKind") "results", JsonValue.Array(Array.ofList results) "invocations", concat "invocations" |] |] |] File.WriteAllText(target, merged.ToString()) | _ -> failwith "The analyzers wrote no report to merge." /// Runs the analyzers over the given targets, one process per project, several at a time. /// /// A single process walking every project in turn takes minutes and says nothing until the last one /// is done, which is a long time to stare at a blank terminal. Each project is instead analyzed on /// its own, and its output is held back and printed in one piece as that project finishes, so /// findings arrive while the run is still going and no two projects can interleave their lines. /// /// A target that names files is analyzed for those files alone. The project is still loaded and /// type checked, but a whole project is checked file by file, so looking at one file of /// `Fantomas.Core.Tests` takes seconds where the whole project takes minutes. /// /// Whatever is analyzed here is what `analysis.sarif` holds afterwards, so a run over a couple of /// files replaces the report of an earlier run over the solution. /// /// The local rules that report at error severity, and so fail a run when they fire. /// /// `AnalyzeChanged` demotes these, because the run you do while working should report everything /// and stop for nothing. `Analyze` leaves them alone, so CI is where they bite. let localErrorRules: string list = [ "FANTOMAS-PIPEBACK-001"; "FANTOMAS-PRIVATE-001" ] /// The local analyzers that are kept out of the full run. /// /// They report on debt that predates them, and a finding in `Analyze` becomes a code scanning alert /// on the pull request whatever its severity. `AnalyzeChanged` still runs them, over the files you /// touched, which is the scope both rules ask for. Drop one of these once its debt is gone. /// /// `FANTOMAS-KEEPINDENT-001` and `FANTOMAS-OPENS-001` are deliberately not here. Both arrived with /// debt of their own, and both times that debt was cleared in the change that added the rule, so /// the full run has nothing old to report and anything it does report is something the change in /// front of you introduced. let localAdvisoryAnalyzers: string list = [ "AnnotationAnalyzer"; "UnnecessaryParensAnalyzer" ] /// The codes of those same rules, which is what a finding carries. let localAdvisoryCodes: Set = set [ "FANTOMAS-ANNOTATE-001"; "FANTOMAS-PARENS-001" ] /// Decides whether a finding is worth showing, from its rule, its file and its line. `Analyze` /// shows all of them; only `AnalyzeChanged` narrows. type FindingFilter = string -> string -> int -> bool let everyFinding: FindingFilter = fun _ _ _ -> true /// Whether a finding is one this run set out to report. /// /// Two questions, in order. **Is the file one the working tree changed?** If not the finding is /// dropped whatever its rule, because `AnalyzeChanged` reports on the code in front of you and this /// is not it. That test only started mattering once a changed `.fsproj` began asking for the whole /// project: analysing every file of `Fantomas.Tests` to report on the two you added buries them /// under the project's existing debt, and a run whose findings you have to hand-filter is a run that /// tells you nothing. /// /// **And, for the advisory rules, is it on a line that changed?** A file is a much coarser scope /// than they ask for: one line changed in a file of several thousand otherwise surfaces every /// unannotated binding and every stray pair of parentheses in it, and both rules are guidance for /// the code you are writing rather than a reason to sweep the file. Every other rule reports /// anywhere in a file you edited, which is the scope those rules do ask for. /// /// A file git has never seen is new in its entirety, so everything in it is worth reporting. let keepFinding (scopes: Map) : FindingFilter = fun (code: string) (path: string) (line: int) -> match scopeFor scopes path with | None -> false | Some WholeFile -> true | Some(Lines lines) -> not (localAdvisoryCodes.Contains code) || Set.contains line lines /// Drops the advisory findings that sit on lines the working tree did not touch. /// /// `AnalyzeChanged` scopes itself to the files you edited, which for the two advisory rules is much /// coarser than they ask for: one line changed in a file of several thousand surfaces every /// unannotated binding and every stray pair of parentheses in it, where both rules are about the /// code you are writing. The other rules are left alone, because a finding from one of those is /// worth seeing wherever it is. /// /// Reads the tool's own output format. Anything it cannot parse is kept, so a change upstream makes /// this stop narrowing rather than start hiding. let narrowOutput (keep: FindingFilter) (output: string) : string = let finding: Text.RegularExpressions.Regex = Text.RegularExpressions.Regex(@"^(?.+?)\((?\d+),\d+\): \w+ (?[A-Z][A-Z0-9-]*) :") output.Split('\n') |> Array.filter (fun (line: string) -> let m: Text.RegularExpressions.Match = finding.Match(line.TrimStart('\u001b').TrimStart()) if not m.Success then true else keep m.Groups["code"].Value m.Groups["path"].Value (int m.Groups["line"].Value)) |> String.concat "\n" /// The same narrowing, over the report a project just wrote, so that `analysis.sarif` and what was /// printed say the same thing. let narrowReport (keep: FindingFilter) (report: string) : unit = if File.Exists report then let document: JsonValue = JsonValue.Parse(File.ReadAllText report) let keepResult (result: JsonValue) : bool = match result.TryGetProperty "ruleId" with | None -> true | Some ruleId -> let location: JsonValue = result.GetProperty("locations").AsArray().[0].GetProperty("physicalLocation") let path: string = location.GetProperty("artifactLocation").GetProperty("uri").AsString() let line: int = location.GetProperty("region").GetProperty("startLine").AsInteger() keep (ruleId.AsString()) path line // The tool writes one `tool.driver.rules` entry per finding, whose `name` is that finding's // own message rather than the rule's. Filtering `results` and leaving the rules alone // therefore leaves every dropped finding's message behind, and a report whose `results` is // empty while `rules` still spells out seventy findings reads as a contradiction, and is // one. So the rules no surviving result points at go too, and what is left is renumbered, // because `ruleIndex` addresses that array by position. let narrowRun (run: JsonValue) : JsonValue = match run.TryGetProperty "results" with | None -> run | Some results -> let kept: JsonValue array = Array.filter keepResult (results.AsArray()) let rules: JsonValue array = rulesOf run let referenced: int array = kept |> Array.choose (fun (result: JsonValue) -> result.TryGetProperty "ruleIndex" |> Option.map (fun (index: JsonValue) -> index.AsInteger())) |> Array.filter (fun (index: int) -> index >= 0 && index < rules.Length) |> Array.distinct |> Array.sort let renumbered: Map = referenced |> Array.mapi (fun (position: int) (original: int) -> original, position) |> Map.ofArray let repointed: JsonValue array = kept |> Array.map (fun (result: JsonValue) -> match result.TryGetProperty "ruleIndex" with | None -> result | Some index -> match Map.tryFind (index.AsInteger()) renumbered with | None -> result | Some position -> withProperty "ruleIndex" (JsonValue.Number(decimal position)) result) run |> withProperty "results" (JsonValue.Array repointed) |> withRules (Array.map (fun (index: int) -> rules[index]) referenced) let runs: JsonValue array = document.GetProperty("runs").AsArray() |> Array.map narrowRun let narrowed: JsonValue = JsonValue.Record [| for name, value in document.Properties() -> if name = "runs" then name, JsonValue.Array runs else name, value |] File.WriteAllText(report, narrowed.ToString()) /// Returns the highest exit code of the runs, so a project the analyzers could not process fails /// the stage rather than passing for want of findings. /// /// `extraArguments` is passed to every invocation, and is how the two pipelines differ. let analyzeTargets (extraArguments: string list) (keep: FindingFilter) (targets: AnalysisTarget list) : Async = async { let! analyzers = analyzerPaths () if Directory.Exists analysisReportsDir then Directory.Delete(analysisReportsDir, true) Directory.CreateDirectory analysisReportsDir |> ignore let names = targets |> List.map (fun (target: AnalysisTarget) -> Path.GetFileNameWithoutExtension target.Project) |> String.concat ", " let count: string = match targets.Length with | 1 -> "1 project" | n -> $"{n} projects" printfn $"Analyzing {count}: {names}" let analyzeProject (target: AnalysisTarget) = async { let name = Path.GetFileNameWithoutExtension target.Project let report = analysisReportsDir $"{name}.sarif" let started = DateTime.UtcNow let arguments = [ "fsharp-analyzers" // Neither of these is source anybody wrote. The test SDK generates its // entry point into the compilation from the package cache, and MSBuild // generates an `AssemblyInfo` per project under `obj`. Both are part of // what gets type checked, and a finding in either is not a finding about // this repository. `AssemblyInfo` earns its place here because it opens // `System` and `System.Reflection` and then writes every attribute out // fully qualified, so `FANTOMAS-OPENS-001` has two true things to say // about each of them and nowhere to say them. "--exclude-files" "**/Microsoft.NET.Test.Sdk.Program.fs" "**/*.AssemblyInfo.fs" for analyzer in analyzers do "--analyzers-path" analyzer // One flag, then every file. Repeating the flag is an error, and the tool // answers it by printing its help and finding nothing, which reads as a // clean project. match target.Files with | [] -> () | files -> "--include-files" yield! files "--code-root" repositoryRoot "--report" report yield! extraArguments "--project" repositoryRoot target.Project ] let! result = Cli .Wrap("dotnet") .WithArguments(arguments) .WithWorkingDirectory(repositoryRoot) .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync() .Task |> Async.AwaitTask narrowReport keep report let elapsed = DateTime.UtcNow - started let findings = sarifResultCount report // A non-zero exit is worth saying out loud. The tool exits non-zero both for a // finding at error severity and for a run that never happened, and a bare // "no findings" would read the same either way. let summary = match result.ExitCode, findings with | 0, 0 -> "no findings" | 0, 1 -> "1 finding" | 0, n -> $"{n} findings" | code, 0 -> $"no findings, exit code {code}" | code, n -> $"{n} findings, exit code {code}" let scope = match target.Files with | [] -> "" | [ _ ] -> " (1 file)" | files -> $" ({files.Length} files)" printfn $"\n=== {name}{scope}: {summary} in {elapsed.TotalSeconds:F1}s" printf "%s" (narrowOutput keep result.StandardOutput) eprintf "%s" result.StandardError return report, result.ExitCode } // Every analyzer process type checks a whole project, so a handful at a time is what keeps // the machine busy without the runs starving each other of memory. let! results = Async.Parallel(List.map analyzeProject targets, max 2 (Environment.ProcessorCount / 2)) mergeSarifReports (results |> Array.map fst |> List.ofArray) (mergedAnalysisReport) return results |> Array.map snd |> Array.fold max 0 } /// Each of these takes a list of values after a single flag. Repeating the flag is an error. let excludeLocalAdvisory: string list = "--exclude-analyzers" :: localAdvisoryAnalyzers ``` ::: :::tab Ported ```fsharp title="scripts/BuildAnalyzers2.fsx" showLineNumbers #r "nuget: FSharp.Data, 6.3.0" #load "BuildCommon2.fsx" open System open System.IO open System.Xml.Linq open System.Xml.XPath open FSharp.Data open Partas.Build open BuildCommon2 // Running the analyzers, and deciding which of their findings a run set out to report. /// The projects the analyzers run over: every project in the solution minus the ones whose source /// is not ours to change. Largest first, so the longest run is never the one left waiting for a slot. let projectsToAnalyze: string list = let excluded = set [ "Fantomas.FCS" ] let sourceSize (project: string) = Directory.EnumerateFiles(Path.GetDirectoryName(repositoryRoot project), "*.fs", SearchOption.AllDirectories) |> Seq.sumBy (fun file -> FileInfo(file).Length) XDocument.Load(repositoryRoot "fantomas.slnx").XPathSelectElements("//Project") |> Seq.map (fun project -> project.Attribute(XName.Get "Path").Value.Replace('\\', '/')) |> Seq.filter (fun path -> not (excluded.Contains(Path.GetFileNameWithoutExtension path))) |> Seq.sortByDescending sourceSize |> Seq.toList /// One project to hand to the analyzers, and which of its files to look at. `Files` holds absolute /// paths, the only form `--include-files` matches; an empty list asks for every file of the project. type AnalysisTarget = { Project: string; Files: string list } /// What to analyze for a set of changed files: every project that owns one, along with the files of /// its own that changed. A changed project file asks for the whole project. let targetsFor (files: string list) : AnalysisTarget list = let sources: string list = List.filter (hasExtension [ ".fs"; ".fsi" ]) files let projectFiles: string list = List.filter (hasExtension [ ".fsproj" ]) files projectsToAnalyze |> List.choose (fun (project: string) -> let folder: string = project.Substring(0, project.LastIndexOf '/' + 1) let owns (file: string) : bool = file.StartsWith(folder, StringComparison.Ordinal) if List.exists owns projectFiles then Some { Project = project; Files = [] } else match List.filter owns sources with | [] -> None | owned -> Some { Project = project Files = List.map (fun (file: string) -> repositoryRoot file) owned }) /// Where the analyzer project this repository owns is built to. Handed to `--analyzers-path` as a /// folder rather than `analyzers`, so the SDK's recursive search does not also find the test assembly. let localAnalyzerPath: string = repositoryRoot "analyzers" "Fantomas.Analyzers" "bin" "Release" "net8.0" let buildLocalAnalyzers: string = "dotnet build analyzers/Fantomas.Analyzers -c Release --tl" /// Where the analyzers live on disk: the two package references at their restored paths, and ours. let analyzerPaths () : Async = async { if not (File.Exists(localAnalyzerPath "Fantomas.Analyzers.dll")) then failwith $"The local analyzers are not built. Expected an assembly in {localAnalyzerPath}.\nRun `dotnet build analyzers/Fantomas.Analyzers -c Release` first." let! exitCode, stdout, stderr = Proc.buffered ( cmd $"dotnet msbuild src/Fantomas/Fantomas.fsproj -getProperty:PkgIonide_Analyzers -getProperty:PkgG-Research_FSharp_Analyzers" ) if exitCode <> 0 then failwith $"Could not resolve the analyzer packages. Run `dotnet restore` first.\n{stderr}" let properties = JsonValue.Parse(stdout).GetProperty("Properties") return [ for name, value in properties.Properties() do match value.AsString() with | "" -> failwith $"MSBuild has no value for {name}. Run `dotnet restore` first." | path -> path "analyzers" "dotnet" "fs" localAnalyzerPath ] } /// The number of results a single analyzer report holds. let sarifResultCount (report: string) : int = if not (File.Exists report) then 0 else JsonValue.Parse(File.ReadAllText report).GetProperty("runs").AsArray() |> Array.sumBy (fun run -> match run.TryGetProperty "results" with | Some results -> results.AsArray().Length | None -> 0) /// The same record with one property replaced. let withProperty (name: string) (value: JsonValue) (record: JsonValue) : JsonValue = JsonValue.Record [| for existing, current in record.Properties() -> if existing = name then existing, value else existing, current |] /// A run's `tool.driver.rules`, which is empty when it has none. let rulesOf (run: JsonValue) : JsonValue array = run.TryGetProperty "tool" |> Option.bind (fun (tool: JsonValue) -> tool.TryGetProperty "driver") |> Option.bind (fun (driver: JsonValue) -> driver.TryGetProperty "rules") |> Option.map (fun (rules: JsonValue) -> rules.AsArray()) |> Option.defaultValue [||] /// The same run carrying these rules instead. let withRules (rules: JsonValue array) (run: JsonValue) : JsonValue = match run.TryGetProperty "tool" with | None -> run | Some tool -> match tool.TryGetProperty "driver" with | None -> run | Some driver -> let driver: JsonValue = withProperty "rules" (JsonValue.Array rules) driver withProperty "tool" (withProperty "driver" driver tool) run /// Folds the per-project reports into the one SARIF run GitHub code scanning takes. Every result is /// repointed at where its rule ended up in the merged rules array, and identical rules collapse. let mergeSarifReports (reports: string list) (target: string) : unit = let documents = reports |> List.filter File.Exists |> List.map (fun report -> JsonValue.Parse(File.ReadAllText report)) let runs = documents |> List.collect (fun document -> document.GetProperty("runs").AsArray() |> List.ofArray) match documents, runs with | firstDocument :: _, firstRun :: _ -> let concat (name: string) = runs |> List.collect (fun run -> match run.TryGetProperty name with | Some array -> List.ofArray (array.AsArray()) | None -> []) |> Array.ofList |> JsonValue.Array let rules, results = let merged: ResizeArray = ResizeArray() let seen: Collections.Generic.Dictionary = Collections.Generic.Dictionary() let indexOf (rule: JsonValue) : int = let key: string = rule.ToString() match seen.TryGetValue key with | true, index -> index | false, _ -> let index: int = merged.Count merged.Add rule seen[key] <- index index let results: JsonValue list = runs |> List.collect (fun (run: JsonValue) -> let placed: int array = Array.map indexOf (rulesOf run) let repoint (result: JsonValue) : JsonValue = match result.TryGetProperty "ruleIndex" with | None -> result | Some index -> let original: int = index.AsInteger() if original >= 0 && original < placed.Length then withProperty "ruleIndex" (JsonValue.Number(decimal placed[original])) result else result match run.TryGetProperty "results" with | None -> [] | Some results -> results.AsArray() |> Array.map repoint |> List.ofArray) List.ofSeq merged, results let merged = JsonValue.Record [| "$schema", firstDocument.GetProperty("$schema") "version", firstDocument.GetProperty("version") "runs", JsonValue.Array [| JsonValue.Record [| "tool", (withRules (Array.ofList rules) firstRun).GetProperty("tool") "columnKind", firstRun.GetProperty("columnKind") "results", JsonValue.Array(Array.ofList results) "invocations", concat "invocations" |] |] |] File.WriteAllText(target, merged.ToString()) | _ -> failwith "The analyzers wrote no report to merge." /// The local rules that report at error severity, and so fail a run when they fire. let localErrorRules: string list = [ "FANTOMAS-PIPEBACK-001"; "FANTOMAS-PRIVATE-001" ] /// The local analyzers kept out of the full run: they report on debt that predates them. let localAdvisoryAnalyzers: string list = [ "AnnotationAnalyzer"; "UnnecessaryParensAnalyzer" ] /// The codes of those same rules, which is what a finding carries. let localAdvisoryCodes: Set = set [ "FANTOMAS-ANNOTATE-001"; "FANTOMAS-PARENS-001" ] /// Decides whether a finding is worth showing, from its rule, its file and its line. type FindingFilter = string -> string -> int -> bool let everyFinding: FindingFilter = fun _ _ _ -> true /// Keeps a finding in a file the working tree changed. The advisory rules are narrowed further to /// the lines that changed; a file git has never seen is new in its entirety. let keepFinding (scopes: Map) : FindingFilter = fun (code: string) (path: string) (line: int) -> match scopeFor scopes path with | None -> false | Some WholeFile -> true | Some(Lines lines) -> not (localAdvisoryCodes.Contains code) || Set.contains line lines /// Drops the findings `keep` rejects from the tool's console output. Anything that does not parse /// as a finding is kept. let narrowOutput (keep: FindingFilter) (output: string) : string = let finding = Text.RegularExpressions.Regex(@"^(?.+?)\((?\d+),\d+\): \w+ (?[A-Z][A-Z0-9-]*) :") output.Split('\n') |> Array.filter (fun (line: string) -> let m = finding.Match(line.TrimStart('').TrimStart()) if not m.Success then true else keep m.Groups["code"].Value m.Groups["path"].Value (int m.Groups["line"].Value)) |> String.concat "\n" /// The same narrowing over the report a project just wrote, rules renumbered to match. let narrowReport (keep: FindingFilter) (report: string) : unit = if File.Exists report then let document: JsonValue = JsonValue.Parse(File.ReadAllText report) let keepResult (result: JsonValue) : bool = match result.TryGetProperty "ruleId" with | None -> true | Some ruleId -> let location: JsonValue = result.GetProperty("locations").AsArray().[0].GetProperty("physicalLocation") let path: string = location.GetProperty("artifactLocation").GetProperty("uri").AsString() let line: int = location.GetProperty("region").GetProperty("startLine").AsInteger() keep (ruleId.AsString()) path line let narrowRun (run: JsonValue) : JsonValue = match run.TryGetProperty "results" with | None -> run | Some results -> let kept: JsonValue array = Array.filter keepResult (results.AsArray()) let rules: JsonValue array = rulesOf run let referenced: int array = kept |> Array.choose (fun (result: JsonValue) -> result.TryGetProperty "ruleIndex" |> Option.map (fun (index: JsonValue) -> index.AsInteger())) |> Array.filter (fun (index: int) -> index >= 0 && index < rules.Length) |> Array.distinct |> Array.sort let renumbered: Map = referenced |> Array.mapi (fun (position: int) (original: int) -> original, position) |> Map.ofArray let repointed: JsonValue array = kept |> Array.map (fun (result: JsonValue) -> match result.TryGetProperty "ruleIndex" with | None -> result | Some index -> match Map.tryFind (index.AsInteger()) renumbered with | None -> result | Some position -> withProperty "ruleIndex" (JsonValue.Number(decimal position)) result) run |> withProperty "results" (JsonValue.Array repointed) |> withRules (Array.map (fun (index: int) -> rules[index]) referenced) let runs: JsonValue array = document.GetProperty("runs").AsArray() |> Array.map narrowRun let narrowed: JsonValue = JsonValue.Record [| for name, value in document.Properties() -> if name = "runs" then name, JsonValue.Array runs else name, value |] File.WriteAllText(report, narrowed.ToString()) /// Runs the analyzers over the targets, one process per project, several at a time. Each project's /// output is held back and printed whole as it finishes. Returns the highest exit code. let analyzeTargets (extraArguments: string list) (keep: FindingFilter) (targets: AnalysisTarget list) : Async = async { let! analyzers = analyzerPaths () if Directory.Exists analysisReportsDir then Directory.Delete(analysisReportsDir, true) Directory.CreateDirectory analysisReportsDir |> ignore let names = targets |> List.map (fun (target: AnalysisTarget) -> Path.GetFileNameWithoutExtension target.Project) |> String.concat ", " let count: string = match targets.Length with | 1 -> "1 project" | n -> $"{n} projects" printfn $"Analyzing {count}: {names}" let analyzeProject (target: AnalysisTarget) = async { let name = Path.GetFileNameWithoutExtension target.Project let report = analysisReportsDir $"{name}.sarif" let started = DateTime.UtcNow // Generated sources are excluded: the test SDK's entry point and MSBuild's per-project // `AssemblyInfo` are type checked with the rest, and a finding in either is not ours. let arguments = [ "fsharp-analyzers" "--exclude-files" "**/Microsoft.NET.Test.Sdk.Program.fs" "**/*.AssemblyInfo.fs" for analyzer in analyzers do "--analyzers-path" analyzer // One flag, then every file: repeating the flag makes the tool print its help. match target.Files with | [] -> () | files -> "--include-files" yield! files "--code-root" repositoryRoot "--report" report yield! extraArguments "--project" repositoryRoot target.Project ] let! exitCode, stdout, stderr = Proc.buffered (Cmd.ofList "dotnet" arguments) narrowReport keep report let elapsed = DateTime.UtcNow - started let findings = sarifResultCount report let summary = match exitCode, findings with | 0, 0 -> "no findings" | 0, 1 -> "1 finding" | 0, n -> $"{n} findings" | code, 0 -> $"no findings, exit code {code}" | code, n -> $"{n} findings, exit code {code}" let scope = match target.Files with | [] -> "" | [ _ ] -> " (1 file)" | files -> $" ({files.Length} files)" printfn $"\n=== {name}{scope}: {summary} in {elapsed.TotalSeconds:F1}s" printf "%s" (narrowOutput keep stdout) eprintf "%s" stderr return report, exitCode } let! results = Async.Parallel(List.map analyzeProject targets, max 2 (Environment.ProcessorCount / 2)) mergeSarifReports (results |> Array.map fst |> List.ofArray) mergedAnalysisReport return results |> Array.map snd |> Array.fold max 0 } /// Each of these takes a list of values after a single flag. Repeating the flag is an error. let excludeLocalAdvisory: string list = "--exclude-analyzers" :: localAdvisoryAnalyzers let commands = [ command "analyze" { description "Run the analyzers over every project and merge the reports into analysis.sarif" workingDir repositoryRoot Blocks.restoreTools Blocks.restoreSolution stage "build analyzers" { run buildLocalAnalyzers } stage "analyze" { run (fun _ -> projectsToAnalyze |> List.map (fun (project: string) -> { Project = project; Files = [] }) |> analyzeTargets excludeLocalAdvisory everyFinding) } } command "analyze-changed" { description "Run the analyzers over the files the working tree changed; reports everything, fails on nothing" workingDir repositoryRoot Blocks.restoreTools Blocks.restoreSolution stage "build analyzers" { run buildLocalAnalyzers } stage "analyze" { run (fun _ -> async { let! files = changedFiles () let demoteLocalErrors: string list = "--treat-as-warning" :: localErrorRules match targetsFor files with | [] -> printfn "No changed file belongs to a project that is analyzed." return 0 | targets -> let! scopes = changedLines () return! analyzeTargets demoteLocalErrors (keepFinding scopes) targets }) } } ] runIfMain "BuildAnalyzers2.fsx" (fun () -> rootCommandOfScript { name "BuildAnalyzers2.fsx" commands }) ``` ::: :::: ::::: :::::tab BuildRelease.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/BuildRelease.fsx" showLineNumbers #r "nuget: CliWrap, 3.6.4" #r "nuget: FSharp.Data, 6.3.0" #r "nuget: Ionide.KeepAChangelog, 0.1.8" #r "nuget: Humanizer.Core, 2.14.1" open System open System.IO open CliWrap open CliWrap.Buffered open FSharp.Data open Ionide.KeepAChangelog open Ionide.KeepAChangelog.Domain open SemVersion open Humanizer // Loaded by `build.fsx`, after `BuildCommon.fsx`. An error here saying BuildCommon is not defined // means this file was run on its own; it is a library, so run a pipeline from build.fsx instead. open BuildCommon // Working out what a release is: which version is being cut, what changed since the last one, and // the notes that go with it. Reading only, apart from `pushPackage`; the pipelines decide what to do // with any of it. /// Whether this run was asked not to publish anything. let isDryRun: bool = let args = fsi.CommandLineArgs Array.exists (fun arg -> arg = "--dry-run") args /// Push a package to NuGet, unless this run was told not to publish. let pushPackage nupkg = async { if isDryRun then printfn $"[DRY-RUN] Would push package: {nupkg}" return 0 else let key = Environment.GetEnvironmentVariable("NUGET_KEY") let! result = Cli .Wrap("dotnet") .WithArguments( $"nuget push \"{nupkg}\" --api-key \"{key}\" --source https://api.nuget.org/v3/index.json" ) .ExecuteAsync() .Task |> Async.AwaitTask return result.ExitCode } type GithubRelease = { Version: string Title: string Date: DateTime /// None when GitHub has no release for this version: it is not created yet, or the /// version went to NuGet by hand the way 7.0.6 did. PublishedDate: string option Draft: string } let formatVersion (v: SemanticVersion) : string = if String.IsNullOrEmpty v.Prerelease then $"{v.Major}.{v.Minor}.{v.Patch}" else $"{v.Major}.{v.Minor}.{v.Patch}-{v.Prerelease}" /// Releases are ordered on their version and not on their date. A hotfix for an older major is /// released from its own branch, so it can enter the changelog with a date that is newer than /// the entry main is about to release: 7.0.6 is dated after 8.0.0-alpha-013. /// SemanticVersion itself does not support the comparison constraint, hence the tuple. let versionSortKey (v: SemanticVersion) : int * int * int * int * string = let prerelease = if isNull v.Prerelease then String.Empty else v.Prerelease v.Major.GetValueOrDefault(), v.Minor.GetValueOrDefault(), v.Patch.GetValueOrDefault(), // a stable release comes after the prereleases that led up to it (if prerelease = String.Empty then 1 else 0), prerelease /// The date the GitHub release for this version was published. /// None when GitHub has no release for it, which is what happens for a version that was pushed /// to NuGet by hand, like 7.0.6. let getPublishedDate (version: string) : string option = let prefixedVersion = $"v{version}" printfn $"Checking if release {prefixedVersion} already exists on GitHub..." let cmdResult = Cli .Wrap("gh") .WithArguments($"release view {prefixedVersion} --json publishedAt -t \"{{{{.publishedAt}}}}\"") .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync() .Task.Result if cmdResult.ExitCode <> 0 then printfn $"Release {prefixedVersion} does not exist yet" None else let output = cmdResult.StandardOutput.Trim() let lastIdx = output.LastIndexOf("Z", StringComparison.Ordinal) let dateStr = output.Substring(0, lastIdx) printfn $"Release {prefixedVersion} already exists, published at: {dateStr}" Some dateStr let mkGithubRelease (v: SemanticVersion, d: DateTime, cd: ChangelogData option) : GithubRelease = match cd with | None -> failwith "Each Fantomas release is expected to have at least one section." | Some cd -> let version = formatVersion v printfn $"Parsing release version: {version} (prerelease: {not (String.IsNullOrEmpty v.Prerelease)})" let title = let month = d.ToString("MMMM") let day = d.Day.Ordinalize() $"{month} {day} Release" let publishDate = getPublishedDate version let sections = [ "Added", cd.Added "Changed", cd.Changed "Fixed", cd.Fixed "Deprecated", cd.Deprecated "Removed", cd.Removed "Security", cd.Security yield! (Map.toList cd.Custom) ] |> List.choose (fun (header, lines) -> if lines.IsEmpty then None else lines |> List.map (fun line -> line.TrimStart()) |> String.concat "\n" |> sprintf "### %s\n%s" header |> Some) |> String.concat "\n\n" let draft = $"""# {version} {sections}""" { Version = version Title = title Date = d PublishedDate = publishDate Draft = draft } let getReleaseNotes (currentRelease: GithubRelease) (lastPublishedDate: string option) : string = let date = match lastPublishedDate with | Some d -> printfn $"Using last release published date for author attribution: {d}" d | None -> // Query GitHub for the most recent published release printfn "No earlier changelog entry is on GitHub, querying GitHub for most recent release..." let ghReleaseResult = Cli .Wrap("gh") .WithArguments("release list --limit 1 --json createdAt") .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync() .Task.Result if ghReleaseResult.ExitCode = 0 && not (String.IsNullOrWhiteSpace(ghReleaseResult.StandardOutput.Trim())) then let jsonOutput = ghReleaseResult.StandardOutput.Trim() let jsonValue = FSharp.Data.JsonValue.Parse(jsonOutput) let releases = jsonValue.AsArray() if releases.Length > 0 then match releases.[0].TryGetProperty("createdAt") with | Some createdAtJson -> let createdAt = createdAtJson.AsString() // Parse ISO 8601 date and convert back to string format for the query let dateTime = DateTime .Parse(createdAt, null, System.Globalization.DateTimeStyles.RoundtripKind) .ToUniversalTime() let ghDate = dateTime.ToString("yyyy-MM-ddTHH:mm:ss") printfn $"Using most recent GitHub release date for author attribution: {ghDate}" ghDate | None -> let fallbackDate = DateTime.UtcNow.ToString("yyyy-MM-dd") printfn $"GitHub release missing createdAt, using current date: {fallbackDate}" fallbackDate else let fallbackDate = DateTime.UtcNow.ToString("yyyy-MM-dd") printfn $"No GitHub releases found, using current date: {fallbackDate}" fallbackDate else let fallbackDate = DateTime.UtcNow.ToString("yyyy-MM-dd") printfn $"Could not query GitHub releases, using current date: {fallbackDate}" fallbackDate printfn $"Querying PRs closed after {date} for author attribution..." let authorMsg = let queryResult = Cli .Wrap("gh") .WithArguments($"pr list -S \"state:closed base:main closed:>{date}\" --json commits,mergedAt") .WithValidation(CommandResultValidation.None) .ExecuteBufferedAsync() .Task.Result if queryResult.ExitCode <> 0 then printfn $"Warning: Failed to query PRs for author attribution (exit code: {queryResult.ExitCode})" String.Empty else let jsonOutput = queryResult.StandardOutput.Trim() // Parse JSON to filter by mergedAt timestamp let jsonValue = FSharp.Data.JsonValue.Parse(jsonOutput) let prs = jsonValue.AsArray() // Parse the date as ISO 8601 format (GitHub always returns dates in this format: "2025-08-02T10:25:30Z") let cutoffTimestamp = DateTime.Parse(date, null, System.Globalization.DateTimeStyles.RoundtripKind).ToUniversalTime() printfn $"Filtering PRs merged after: {cutoffTimestamp:O}" let authors = prs |> Array.collect (fun (pr: FSharp.Data.JsonValue) -> let mergedAtOpt = match pr.TryGetProperty("mergedAt") with | Some mergedAtJson -> let mergedAtStr = mergedAtJson.AsString() match DateTime.TryParse(mergedAtStr, null, System.Globalization.DateTimeStyles.RoundtripKind) with | true, dt -> Some(dt.ToUniversalTime()) | false, _ -> None | None -> None match mergedAtOpt with | Some mergedAt when mergedAt > cutoffTimestamp -> match pr.TryGetProperty("commits") with | Some commitsJson -> let commits = commitsJson.AsArray() commits |> Array.collect (fun (commit: FSharp.Data.JsonValue) -> match commit.TryGetProperty("authors") with | Some authorsJson -> let commitAuthors = authorsJson.AsArray() commitAuthors |> Array.choose (fun (author: FSharp.Data.JsonValue) -> match author.TryGetProperty("login") with | Some loginJson -> let login = loginJson.AsString() // Filter out bots if login.EndsWith("[bot]", StringComparison.Ordinal) then None else Some(login) | None -> None) | None -> [||]) | None -> [||] | _ -> [||]) |> Array.distinct |> Array.sort printfn $"Found {authors.Length} contributors for this release" if authors.Length = 0 then String.Empty elif authors.Length = 1 then $"Special thanks to @%s{authors.[0]}!" else let lastAuthor = Array.last authors let otherAuthors = if authors.Length = 2 then $"@{authors.[0]}" else authors |> Array.take (authors.Length - 1) |> Array.map (sprintf "@%s") |> String.concat ", " $"Special thanks to %s{otherAuthors} and @%s{lastAuthor}!" $"""{currentRelease.Draft} {authorMsg} [https://www.nuget.org/packages/fantomas/{currentRelease.Version}](https://www.nuget.org/packages/fantomas/{currentRelease.Version}) """ let getCurrentReleaseAndLastPublishedDate () : GithubRelease * string option = printfn "Parsing CHANGELOG.md to find current and last release..." let changelog = FileInfo(repositoryRoot "CHANGELOG.md") let changeLogResult = match Parser.parseChangeLog changelog with | Error error -> failwithf "Failed to parse changelog: %A" error | Ok result -> printfn $"Found {result.Releases.Length} releases in changelog" result let releases = changeLogResult.Releases |> List.sortByDescending (fun (v, _, _) -> versionSortKey v) match releases with | [] -> failwith "Could not find any release in CHANGELOG.md" | current :: earlierReleases -> let currentRelease = mkGithubRelease current printfn $"Current release: {currentRelease.Version}" // The release below the current one does not have to exist on GitHub: 7.0.6 went to // NuGet by hand from the v7.0.6 branch and never got a GitHub release. Walk down the // recent entries until GitHub knows one, its publish date is what the contributor // query is based on. Anything older than that is out of date anyway, getReleaseNotes // then falls back to the most recent release GitHub reports. let lastPublishedRelease = earlierReleases |> List.truncate 5 |> List.tryPick (fun (v, _, _) -> let version = formatVersion v getPublishedDate version |> Option.map (fun date -> version, date)) match lastPublishedRelease with | Some(version, date) -> printfn $"Last release on GitHub: {version}, published at {date}" | None -> printfn "None of the recent changelog entries has a GitHub release" currentRelease, Option.map snd lastPublishedRelease ``` ::: :::tab Ported ```fsharp title="scripts/BuildRelease2.fsx" showLineNumbers #r "nuget: FSharp.Data, 6.3.0" #r "nuget: Ionide.KeepAChangelog, 0.1.8" #r "nuget: Humanizer.Core, 2.14.1" #load "BuildCommon2.fsx" open System open System.IO open FSharp.Data open Ionide.KeepAChangelog open Ionide.KeepAChangelog.Domain open SemVersion open Humanizer open Partas.Build open BuildCommon2 // Working out what a release is: which version is being cut, what changed since the last one, and // the notes that go with it. Reading only, apart from `pushPackage`. /// Pushes a package to NuGet. The key is masked wherever the command is printed. let pushPackage (key: string option) (dryRun: bool) (nupkg: string) : Async = let push = cmd $"dotnet nuget push {nupkg} --source https://api.nuget.org/v3/index.json" |> Cmd.secretOptionWhenSome "--api-key" key if dryRun then printfn $"[DRY-RUN] Would run: {Cmd.toLogString push}" async { return 0 } else Proc.stream push type GithubRelease = { Version: string Title: string Date: DateTime /// None when GitHub has no release for this version: it is not created yet, or the /// version went to NuGet by hand the way 7.0.6 did. PublishedDate: string option Draft: string } let formatVersion (v: SemanticVersion) : string = if String.IsNullOrEmpty v.Prerelease then $"{v.Major}.{v.Minor}.{v.Patch}" else $"{v.Major}.{v.Minor}.{v.Patch}-{v.Prerelease}" /// Releases are ordered on their version and not on their date: a hotfix for an older major is /// released from its own branch, so it can carry a date newer than the entry main is about to cut. let versionSortKey (v: SemanticVersion) : int * int * int * int * string = let prerelease = if isNull v.Prerelease then String.Empty else v.Prerelease v.Major.GetValueOrDefault(), v.Minor.GetValueOrDefault(), v.Patch.GetValueOrDefault(), (if prerelease = String.Empty then 1 else 0), prerelease /// The date the GitHub release for this version was published, or None when GitHub has none. let getPublishedDate (version: string) : string option = let prefixedVersion = $"v{version}" printfn $"Checking if release {prefixedVersion} already exists on GitHub..." let exitCode, stdout, _ = Proc.buffered (cmd $"gh release view {prefixedVersion} --json publishedAt -t {{{{.publishedAt}}}}") |> Async.RunSynchronously if exitCode <> 0 then printfn $"Release {prefixedVersion} does not exist yet" None else let output = stdout.Trim() let lastIdx = output.LastIndexOf("Z", StringComparison.Ordinal) let dateStr = output.Substring(0, lastIdx) printfn $"Release {prefixedVersion} already exists, published at: {dateStr}" Some dateStr let mkGithubRelease (v: SemanticVersion, d: DateTime, cd: ChangelogData option) : GithubRelease = match cd with | None -> failwith "Each Fantomas release is expected to have at least one section." | Some cd -> let version = formatVersion v printfn $"Parsing release version: {version} (prerelease: {not (String.IsNullOrEmpty v.Prerelease)})" let title = let month = d.ToString("MMMM") let day = d.Day.Ordinalize() $"{month} {day} Release" let publishDate = getPublishedDate version let sections = [ "Added", cd.Added "Changed", cd.Changed "Fixed", cd.Fixed "Deprecated", cd.Deprecated "Removed", cd.Removed "Security", cd.Security yield! (Map.toList cd.Custom) ] |> List.choose (fun (header, lines) -> if lines.IsEmpty then None else lines |> List.map (fun line -> line.TrimStart()) |> String.concat "\n" |> sprintf "### %s\n%s" header |> Some) |> String.concat "\n\n" let draft = $"""# {version} {sections}""" { Version = version Title = title Date = d PublishedDate = publishDate Draft = draft } /// The most recent release date GitHub knows, as the cutoff for contributor attribution. let private mostRecentGithubReleaseDate () : string = let exitCode, stdout, _ = Proc.buffered (cmd $"gh release list --limit 1 --json createdAt") |> Async.RunSynchronously let fallback (reason: string) = let date = DateTime.UtcNow.ToString("yyyy-MM-dd") printfn $"{reason}, using current date: {date}" date if exitCode <> 0 || String.IsNullOrWhiteSpace(stdout.Trim()) then fallback "Could not query GitHub releases" else let releases = JsonValue.Parse(stdout.Trim()).AsArray() if releases.Length = 0 then fallback "No GitHub releases found" else match releases.[0].TryGetProperty("createdAt") with | None -> fallback "GitHub release missing createdAt" | Some createdAt -> let dateTime = DateTime .Parse(createdAt.AsString(), null, Globalization.DateTimeStyles.RoundtripKind) .ToUniversalTime() let ghDate = dateTime.ToString("yyyy-MM-ddTHH:mm:ss") printfn $"Using most recent GitHub release date for author attribution: {ghDate}" ghDate /// The GitHub logins of everyone whose commits were merged to main after `date`, bots excluded. let private contributorsSince (date: string) : string array = printfn $"Querying PRs closed after {date} for author attribution..." let query = $"state:closed base:main closed:>{date}" let exitCode, stdout, _ = Proc.buffered (cmd $"gh pr list -S {query} --json commits,mergedAt") |> Async.RunSynchronously if exitCode <> 0 then printfn $"Warning: Failed to query PRs for author attribution (exit code: {exitCode})" [||] else let cutoff = DateTime.Parse(date, null, Globalization.DateTimeStyles.RoundtripKind).ToUniversalTime() printfn $"Filtering PRs merged after: {cutoff:O}" let property (name: string) (value: JsonValue) = value.TryGetProperty name let mergedAfterCutoff (pr: JsonValue) = match property "mergedAt" pr with | Some mergedAt -> match DateTime.TryParse(mergedAt.AsString(), null, Globalization.DateTimeStyles.RoundtripKind) with | true, dt -> dt.ToUniversalTime() > cutoff | _ -> false | None -> false JsonValue.Parse(stdout.Trim()).AsArray() |> Array.filter mergedAfterCutoff |> Array.collect (fun pr -> property "commits" pr |> Option.map (fun commits -> commits.AsArray()) |> Option.defaultValue [||]) |> Array.collect (fun commit -> property "authors" commit |> Option.map (fun authors -> authors.AsArray()) |> Option.defaultValue [||]) |> Array.choose (fun author -> property "login" author |> Option.map (fun login -> login.AsString()) |> Option.filter (fun login -> not (login.EndsWith("[bot]", StringComparison.Ordinal)))) |> Array.distinct |> Array.sort let getReleaseNotes (currentRelease: GithubRelease) (lastPublishedDate: string option) : string = let date = match lastPublishedDate with | Some d -> printfn $"Using last release published date for author attribution: {d}" d | None -> printfn "No earlier changelog entry is on GitHub, querying GitHub for most recent release..." mostRecentGithubReleaseDate () let authors = contributorsSince date printfn $"Found {authors.Length} contributors for this release" let authorMsg = match authors with | [||] -> String.Empty | [| one |] -> $"Special thanks to @%s{one}!" | _ -> let lastAuthor = Array.last authors let otherAuthors = authors |> Array.take (authors.Length - 1) |> Array.map (sprintf "@%s") |> String.concat ", " $"Special thanks to %s{otherAuthors} and @%s{lastAuthor}!" $"""{currentRelease.Draft} {authorMsg} [https://www.nuget.org/packages/fantomas/{currentRelease.Version}](https://www.nuget.org/packages/fantomas/{currentRelease.Version}) """ let getCurrentReleaseAndLastPublishedDate () : GithubRelease * string option = printfn "Parsing CHANGELOG.md to find current and last release..." let changelog = FileInfo(repositoryRoot "CHANGELOG.md") let changeLogResult = match Parser.parseChangeLog changelog with | Error error -> failwithf "Failed to parse changelog: %A" error | Ok result -> printfn $"Found {result.Releases.Length} releases in changelog" result let releases = changeLogResult.Releases |> List.sortByDescending (fun (v, _, _) -> versionSortKey v) match releases with | [] -> failwith "Could not find any release in CHANGELOG.md" | current :: earlierReleases -> let currentRelease = mkGithubRelease current printfn $"Current release: {currentRelease.Version}" // The release below the current one does not have to exist on GitHub. Walk down the recent // entries until GitHub knows one; its publish date is what the contributor query is based on. let lastPublishedRelease = earlierReleases |> List.truncate 5 |> List.tryPick (fun (v, _, _) -> let version = formatVersion v getPublishedDate version |> Option.map (fun date -> version, date)) match lastPublishedRelease with | Some(version, date) -> printfn $"Last release on GitHub: {version}, published at {date}" | None -> printfn "None of the recent changelog entries has a GitHub release" currentRelease, Option.map snd lastPublishedRelease /// Every package `pack` produced, except the client, which has a release cycle of its own. let private packagesToPush () : string array = Directory.EnumerateFiles(packagesDir, "*.nupkg", SearchOption.TopDirectoryOnly) |> Seq.filter (fun nupkg -> not (nupkg.Contains("Fantomas.Client"))) |> Seq.toArray /// Pushes the packages and creates the GitHub release, unless the release already exists. let private releaseStage (key: string option) (dryRun: bool) : Async = async { if dryRun then printfn "[DRY-RUN] Starting release pipeline in dry-run mode" else printfn "Starting release pipeline" let currentRelease, lastPublishedDate = getCurrentReleaseAndLastPublishedDate () if Option.isSome currentRelease.PublishedDate then printfn $"Release {currentRelease.Version} already exists on GitHub. Skipping release process." return 0 else printfn $"Release {currentRelease.Version} does not exist yet. Proceeding with release process." let isPrerelease = currentRelease.Version.Contains("-") if isPrerelease then printfn $"Detected prerelease version: {currentRelease.Version}" let nugetPackages = packagesToPush () printfn $"Found {nugetPackages.Length} packages to push to NuGet:" nugetPackages |> Array.iter (fun pkg -> printfn $" - {Path.GetFileName(pkg)}") let! nugetExitCodes = nugetPackages |> Array.map (pushPackage key dryRun) |> Async.Sequential if nugetExitCodes |> Array.forall (fun code -> code = 0) then printfn "All NuGet packages pushed successfully" else let exitCodesStr = nugetExitCodes |> Array.map string |> String.concat ", " printfn $"Warning: Some NuGet packages failed to push. Exit codes: {exitCodesStr}" let notes = getReleaseNotes currentRelease lastPublishedDate printfn "Release notes that will be used:" printfn "---" printfn "%s" notes printfn "---" let noteFile = Path.GetTempFileName() File.WriteAllText(noteFile, notes) // A stable minor or major goes out as a draft, so notes can be added by hand before it // is published. A revision or a prerelease is published as it is. let patchVersion = match currentRelease.Version.Split('-').[0].Split('.') with | [| _; _; patch |] -> match Int32.TryParse patch with | true, p -> p | _ -> 0 | _ -> 0 let isRevision = patchVersion <> 0 let isDraft = not isRevision && not isPrerelease let releaseType = if isPrerelease then "prerelease (published)" elif isRevision then "revision (published)" else "minor/major (draft)" printfn $"Release type: {releaseType}" let releaseCommand = cmd $"gh release create v{currentRelease.Version} --title {currentRelease.Title} --notes-file {noteFile}" |> Cmd.args (List.ofArray nugetPackages) |> Cmd.argIf isDraft [ "--draft" ] |> Cmd.argIf isPrerelease [ "--prerelease" ] let! releaseExitCode = if dryRun then printfn $"[DRY-RUN] Would execute: {Cmd.toLogString releaseCommand}" async { return 0 } else printfn $"Creating GitHub release: v{currentRelease.Version}" Proc.stream releaseCommand if File.Exists noteFile then File.Delete(noteFile) if releaseExitCode = 0 then printfn $"Successfully created GitHub release: v{currentRelease.Version}" else printfn $"Warning: GitHub release creation returned exit code: {releaseExitCode}" return Seq.max [| yield! nugetExitCodes; yield releaseExitCode |] } let release = input { let! dryRun = Options.dryRun and! key = Options.nugetKey return stage "release" { run (fun _ -> releaseStage key dryRun) } } let publishAlpha = input { let! dryRun = Options.dryRun and! key = Options.nugetKey return stage "publish" { run (fun _ -> async { let! exitCodes = packagesToPush () |> Array.map (pushPackage key dryRun) |> Async.Sequential return Array.sum exitCodes }) } } let pushClient = input { let! dryRun = Options.dryRun and! key = Options.nugetKey return stage "push" { run (fun _ -> async { match Directory.EnumerateFiles( packagesDir, "Fantomas.Client.*.nupkg", SearchOption.TopDirectoryOnly ) |> Seq.tryExactlyOne with | Some nupkg -> return! pushPackage key dryRun nupkg | None -> printfn "Fantomas.Client package was not found." return -1 }) } } let commands = [ command "release" { description "Build, test, pack, push to NuGet and create the GitHub release for the current CHANGELOG entry" workingDir repositoryRoot Blocks.build Blocks.test Blocks.pack release } command "publish-alpha" { description "Clean, build, pack and push every package except the client to NuGet" workingDir repositoryRoot Blocks.clean [ analysisReportsDir; artifactsDir ] Blocks.build Blocks.pack publishAlpha } command "push-client" { description "Pack and push Fantomas.Client to NuGet" workingDir repositoryRoot stage "pack" { run "dotnet pack ./src/Fantomas.Client -c Release --tl" } pushClient } ] runIfMain "BuildRelease2.fsx" (fun () -> rootCommandOfScript { name "BuildRelease2.fsx" commands }) ``` ::: :::: ::::: :::::tab BuildCompiler.fsx ::::tabs before-after :::tab Original ```fsharp title="scripts/BuildCompiler.fsx" showLineNumbers #r "nuget: CliWrap, 3.6.4" #r "nuget: FSharp.Data, 6.3.0" open System.IO open System.Xml.Linq open System.Xml.XPath open FSharp.Data // Loaded by `build.fsx`, after `BuildCommon.fsx`. An error here saying BuildCommon is not defined // means this file was run on its own; it is a library, so run a pipeline from build.fsx instead. open BuildCommon // Keeping the vendored FCS sources up to date: which upstream commit they came from, and fetching a // file at that commit. `Fantomas.FCS` is a copy of the compiler, so this is how the copy moves. let deps = repositoryRoot ".deps" let fsharpCompilerHash = let xDoc = XElement.Load(repositoryRoot "Directory.Build.props") xDoc.XPathSelectElements("//FCSCommitHash") |> Seq.head |> (fun xe -> xe.Value) let updateFileRaw (file: FileInfo) = let lines = File.ReadAllLines file.FullName let updatedLines = lines |> Array.map (fun line -> if line.StartsWith("namespace FSharp.Build") then line.Replace("namespace FSharp.Build", "namespace Fantomas.FCS.Build") elif line.Contains("FSharp.Compiler") then line.Replace("FSharp.Compiler", "Fantomas.FCS") elif line.Contains("[]") then line.Replace("[]", "[]") else line) File.WriteAllLines(file.FullName, updatedLines) let downloadCompilerFile commitHash relativePath = async { let file = FileInfo(deps commitHash relativePath) if file.Exists && file.Length <> 0 then return () else file.Directory.Create() let fs = file.Create() let fileName = Path.GetFileName(relativePath) let url = $"https://raw.githubusercontent.com/dotnet/fsharp/{commitHash}/{relativePath}" let! response = Http.AsyncRequestStream( url, headers = [| "Content-Disposition", $"attachment; filename=\"{fileName}\"" |] ) if response.StatusCode <> 200 then printfn $"Could not download %s{relativePath}" do! Async.AwaitTask(response.ResponseStream.CopyToAsync(fs)) fs.Close() updateFileRaw file } ``` ::: :::tab Ported ```fsharp title="scripts/BuildCompiler2.fsx" showLineNumbers collapse={66-157} #load "BuildCommon2.fsx" open System.IO open System.Net.Http open System.Xml.Linq open System.Xml.XPath open Partas.Build open BuildCommon2 // Keeping the vendored FCS sources up to date: which upstream commit they came from, and fetching a // file at that commit. `Fantomas.FCS` is a copy of the compiler, so this is how the copy moves. let deps = repositoryRoot ".deps" let fsharpCompilerHash = let xDoc = XElement.Load(repositoryRoot "Directory.Build.props") xDoc.XPathSelectElements("//FCSCommitHash") |> Seq.head |> (fun xe -> xe.Value) let updateFileRaw (file: FileInfo) = let lines = File.ReadAllLines file.FullName let updatedLines = lines |> Array.map (fun line -> if line.StartsWith("namespace FSharp.Build") then line.Replace("namespace FSharp.Build", "namespace Fantomas.FCS.Build") elif line.Contains("FSharp.Compiler") then line.Replace("FSharp.Compiler", "Fantomas.FCS") elif line.Contains("[]") then line.Replace("[]", "[]") else line) File.WriteAllLines(file.FullName, updatedLines) let private http = new HttpClient() /// Fetches one compiler file at the given commit into `.deps`, unless it is already there. let downloadCompilerFile (commitHash: string) (relativePath: string) : Async = async { let file = FileInfo(deps commitHash relativePath) if file.Exists && file.Length <> 0 then return () else file.Directory.Create() let url = $"https://raw.githubusercontent.com/dotnet/fsharp/{commitHash}/{relativePath}" let! response = http.GetAsync url |> Async.AwaitTask if not response.IsSuccessStatusCode then printfn $"Could not download %s{relativePath}" else use fs = file.Create() do! response.Content.CopyToAsync fs |> Async.AwaitTask fs.Close() updateFileRaw file } /// The compiler sources Fantomas.FCS is built from. The first is not a compiler source but the /// MSBuild task that turns FSComp.txt into the SR module, which the SDK's own copy cannot generate /// for the current compiler. let compilerFiles: string array = [| "src/FSharp.Build/FSharpEmbedResourceText.fs" "src/Compiler/FSComp.txt" "src/Compiler/FSStrings.resx" "src/Compiler/Utilities/NullHelpers.fs" "src/Compiler/Utilities/Activity.fsi" "src/Compiler/Utilities/Activity.fs" "src/Compiler/Utilities/Caches.fsi" "src/Compiler/Utilities/Caches.fs" "src/Compiler/Utilities/sformat.fsi" "src/Compiler/Utilities/sformat.fs" "src/Compiler/Utilities/sr.fsi" "src/Compiler/Utilities/sr.fs" "src/Compiler/Facilities/RichText.fsi" "src/Compiler/Facilities/RichText.fs" "src/Compiler/Utilities/ResizeArray.fsi" "src/Compiler/Utilities/ResizeArray.fs" "src/Compiler/Utilities/HashMultiMap.fsi" "src/Compiler/Utilities/HashMultiMap.fs" "src/Compiler/Utilities/ReadOnlySpan.fsi" "src/Compiler/Utilities/ReadOnlySpan.fs" "src/Compiler/Utilities/TaggedCollections.fsi" "src/Compiler/Utilities/TaggedCollections.fs" "src/Compiler/Utilities/illib.fsi" "src/Compiler/Utilities/illib.fs" "src/Compiler/Utilities/Cancellable.fsi" "src/Compiler/Utilities/Cancellable.fs" "src/Compiler/Utilities/FileSystem.fsi" "src/Compiler/Utilities/FileSystem.fs" "src/Compiler/Utilities/ildiag.fsi" "src/Compiler/Utilities/ildiag.fs" "src/Compiler/Utilities/zmap.fsi" "src/Compiler/Utilities/zmap.fs" "src/Compiler/Utilities/zset.fsi" "src/Compiler/Utilities/zset.fs" "src/Compiler/Utilities/XmlAdapters.fsi" "src/Compiler/Utilities/XmlAdapters.fs" "src/Compiler/Utilities/InternalCollections.fsi" "src/Compiler/Utilities/InternalCollections.fs" "src/Compiler/Utilities/lib.fsi" "src/Compiler/Utilities/lib.fs" "src/Compiler/Utilities/PathMap.fsi" "src/Compiler/Utilities/PathMap.fs" "src/Compiler/Utilities/range.fsi" "src/Compiler/Utilities/range.fs" "src/Compiler/Facilities/LanguageFeatures.fsi" "src/Compiler/Facilities/LanguageFeatures.fs" "src/Compiler/Facilities/DiagnosticOptions.fsi" "src/Compiler/Facilities/DiagnosticOptions.fs" "src/Compiler/Facilities/DiagnosticsLogger.fsi" "src/Compiler/Facilities/DiagnosticsLogger.fs" "src/Compiler/Facilities/Hashing.fsi" "src/Compiler/Facilities/Hashing.fs" "src/Compiler/Facilities/prim-lexing.fsi" "src/Compiler/Facilities/prim-lexing.fs" "src/Compiler/Facilities/prim-parsing.fsi" "src/Compiler/Facilities/prim-parsing.fs" "src/Compiler/AbstractIL/illex.fsl" "src/Compiler/AbstractIL/ilpars.fsy" "src/Compiler/AbstractIL/il.fsi" "src/Compiler/AbstractIL/il.fs" "src/Compiler/AbstractIL/ilascii.fsi" "src/Compiler/AbstractIL/ilascii.fs" "src/Compiler/SyntaxTree/PrettyNaming.fsi" "src/Compiler/SyntaxTree/PrettyNaming.fs" "src/Compiler/pplex.fsl" "src/Compiler/pppars.fsy" "src/Compiler/lex.fsl" "src/Compiler/pars.fsy" "src/Compiler/SyntaxTree/UnicodeLexing.fsi" "src/Compiler/SyntaxTree/UnicodeLexing.fs" "src/Compiler/SyntaxTree/XmlDocIncludeExpander.fsi" "src/Compiler/SyntaxTree/XmlDocIncludeExpander.fs" "src/Compiler/SyntaxTree/XmlDoc.fsi" "src/Compiler/SyntaxTree/XmlDoc.fs" "src/Compiler/SyntaxTree/SyntaxTrivia.fsi" "src/Compiler/SyntaxTree/SyntaxTrivia.fs" "src/Compiler/SyntaxTree/SyntaxTree.fsi" "src/Compiler/SyntaxTree/SyntaxTree.fs" "src/Compiler/SyntaxTree/SyntaxTreeOps.fsi" "src/Compiler/SyntaxTree/SyntaxTreeOps.fs" "src/Compiler/SyntaxTree/WarnScopes.fsi" "src/Compiler/SyntaxTree/WarnScopes.fs" "src/Compiler/SyntaxTree/LexerStore.fsi" "src/Compiler/SyntaxTree/LexerStore.fs" "src/Compiler/SyntaxTree/ParseHelpers.fsi" "src/Compiler/SyntaxTree/ParseHelpers.fs" "src/Compiler/SyntaxTree/LexHelpers.fsi" "src/Compiler/SyntaxTree/LexHelpers.fs" "src/Compiler/SyntaxTree/LexFilter.fsi" "src/Compiler/SyntaxTree/LexFilter.fs" |] let commands = [ command "init" { description "Download the vendored compiler sources at the commit Directory.Build.props pins" workingDir repositoryRoot stage "download FCS files" { run (fun _ -> compilerFiles |> Array.map (downloadCompilerFile fsharpCompilerHash) |> Async.Parallel |> Async.Ignore) } } ] runIfMain "BuildCompiler2.fsx" (fun () -> rootCommandOfScript { name "BuildCompiler2.fsx" commands }) ``` ::: :::: ::::: :::::: --- # Capabilities One line per custom operation on the four builders, per `Input` combinator, and per `Cmd` argument helper. The [API reference](https://shayanhabibi.github.io/Partas.Build/reference/) has full signatures and remarks. [Composing reusable blocks](composition.md) has worked examples. ## How settings resolve A stage setting resolves by walking upward: the stage, its parent stage, the parent's parent, then the pipeline. The first level that sets it wins, so a pipeline's `workingDir` covers every stage under it, and a nested stage overrides it for itself and its children. This applies to `workingDir`, `envVars`, the timeouts, `acceptExitCodes`, the output sink, `noPrefixForStep`, `noStdRedirectForStep` and `verbosity`. Conditions are the exception. `when'`, `whenEnvVar`, `whenBranch` and the platform operations **conjoin**: a second condition on the same stage narrows it to the logical AND of both. Use `whenAny { }` to widen. A command's copies of the pipeline settings are **defaults**, not overrides: they reach every pipeline the command runs, but only where that pipeline left the setting alone, regardless of write order. `noPrefixForStep` and `noStdRedirectForStep` are plain bools with no unset state, so a pipeline setting either one to the value `PipelineContext.create` already gives it is indistinguishable from leaving it untouched — the command default overwrites it either way. ## GitHub Actions reporting With `GITHUB_ACTIONS=true`, each active top-level stage opens a collapsible log group and closes it even if the stage fails or is cancelled. Nested stages and parallel work share that group, so concurrent branches do not open overlapping groups. Quiet pipelines emit no group framing. Stage output captures and redirects continue to control step output; workflow group commands go directly to the runner's console. Invoking a `command` or `rootCommand` also appends a Markdown stage timing report to `GITHUB_STEP_SUMMARY` after each pipeline, including failures and quiet runs. It lists every recorded stage in tree order with its outcome and elapsed time; skipped stages show no duration. Names and failure details are escaped for Markdown. Single-stage runs are included, and multiple pipelines append rather than replacing earlier content. An unavailable summary file or a report that would exceed GitHub's 1 MiB per-step limit prints a diagnostic without changing the pipeline result. A direct `PipelineContext.run` call groups its logs but does not write a summary. Both features are automatic in GitHub Actions and leave local console reporting unchanged. ### Structured annotations `annotate` turns an `Annotation` into a deferred `Operation`, used through `runOperation` or composed with other operations. `Annotation.notice message`, `Annotation.warning message` and `Annotation.error message` create diagnostics without metadata. Add `Title`, `File`, `Line`, `EndLine`, `Column` and `EndColumn` through a record update; these fields take `ValueSome` when present. Use repository-relative file paths and one-based line/column numbers. `EndLine` requires `Line`, columns require `Line`, and `EndColumn` requires `Column`. Columns describe ranges within one line; omit them for multiline ranges. Nonpositive positions, reversed ranges or unsupported field combinations raise an argument error when the operation runs. ```fsharp stage "check configuration" { runOperation (annotate { Annotation.warning "Use the new option name." with Title = ValueSome "Deprecated option" File = ValueSome "build.fsx" Line = ValueSome 12 Column = ValueSome 5 EndColumn = ValueSome 18 }) } ``` In GitHub Actions these become runner annotations. Locally they print the level, metadata and literal message. The stage's inherited `GITHUB_ACTIONS` setting selects the format; a stage can override it. Annotations always go to the diagnostic console, independently of captures, redirects and `quiet`. Constructing an operation or running `--explain` emits nothing. An error annotation alone does not fail a stage: return a failure or raise an exception when execution should fail. Compiler logs are not automatically parsed for source locations. The runner's own stage and pipeline errors use the same protocol writer. Titles and messages are escaped separately, and commands bypass console wrapping so long diagnostics remain one physical protocol line. Protocol I/O failures do not change the stage result. ## Timeouts Three names. Meaning shifts with the builder they sit on. | Builder | `timeout` | `timeoutForStage` | `timeoutForStep` | |---|---|---|---| | `stage` | this stage as a whole | — | each step of this stage | | `pipeline` | the whole pipeline run | each stage's default | each step's default | | `command` / `rootCommand` | pipeline default for the whole run | pipeline default for each stage | pipeline default for each step | The unit differs by builder: - `pipeline` — `int`, `float` seconds or a `TimeSpan` - `stage` — plain `int` seconds, `float` seconds or a `TimeSpan` - `command` / `rootCommand` — `int` seconds or a `TimeSpan` ## Stage operations Available inside `stage`, and inside `whenStage`, which accepts everything `stage` does. | Operation | What it does | |---|---| | `run` | Adds a step. Takes a literal command line, a `Cmd`, or a function of the `StageContext` returning `unit`, `int`, `Result`, a `Cmd`, an `Async<_>` or a `Task<_>` of any of those, optionally wrapped in `option`. A function returning a `string` is obsolete: use `runLine` | | `runLine` | Adds a step that runs the command line a function of the `StageContext` returns (`string`, `Async` or `Task`, optionally wrapped in `option`). The line is split on whitespace, honouring quotes | | `runSensitive` | Adds a step from an interpolated command line with every hole masked as `***` wherever the library prints it | | `runOperation` | Adds a step from an `Operation`, with an optional label for `--explain`. Runs under the stage's working directory, environment, acceptable exit codes and output routing | | `runHttpHealthCheck` | Adds a step that polls a URL until it answers or the stage is cancelled | | `echo` | Adds a step that prints a message through the stage's output sink | | `when'` | Runs the stage only when a `bool` holds, or only when a given `StageContext` succeeds. `when' (not quick) "--quick is set"` gives `--explain` the reason it prints against the skipped stage | | `whenEnvVar` | Runs the stage only when an environment variable is set, or set to a given value; also takes an `EnvArg` | | `whenBranch` / `whenBranches` | Runs the stage only on the named git branch. Reads `git branch --show-current` in the stage's working directory; a missing git evaluates false rather than throwing | | `whenWindows` / `whenLinux` / `whenOSX` | Runs the stage only on that platform. Pass `false` to invert | | `whenPlatform` | The same over an `OSPlatform` value | | `workingDir` | The directory this stage's child processes start in. Takes a `string` or a `DirectoryInfo` | | `envVars` | Environment variables for this stage's child processes. Applied to `ProcessStartInfo`, so the host process's own environment is untouched | | `timeout` | Cancels the stage after the given duration | | `timeoutForStep` | Cancels any one step of the stage after the given duration | | `retry` | Runs the stage's steps again after a failing attempt, up to the given count. `timeout` remains the budget for the whole stage, retries included | | `parallel'` | Runs the stage's steps concurrently. `true`/`0`/`-1` unbounded, `1`/`false` sequential, `n` throttled to exactly `n` in flight; also takes a `StageContext -> _` condition | | `consumes` | Adds a step that reads a `DependencySpec<'D>` and runs an `Operation` over its value, and adds the required producers to this stage's prerequisites. See [Producers and dependencies](#producers-and-dependencies) | | `onFailure` | Registers a handler that runs once, after this stage's own `retry` attempts are exhausted. See [Failure handlers](#failure-handlers) | | `acceptExitCodes` | The exit codes that count as success. Replaces the default `[0]` | | `failIfIgnored` | Fails the pipeline when this stage is inactive, instead of skipping it | | `failIfNoActiveSubStage` | Fails the pipeline when none of this stage's sub-stages is active | | `continueStepsOnFailure` | Runs the remaining steps after one fails | | `continueStageOnFailure` | Runs the remaining stages after this one fails | | `continueOnStepFailure` | Both of the above at once | | `outputTo` | Sends this stage's step output to a `StageOutput` — `Console`, `Silent`, `Captured` or `Redirect` | | `silentOutput` | Drops this stage's step output. A failure still reports its exit code | | `captureOutput` | Holds this stage's step output back and lifts it into the error message when a step fails. Takes an optional `OutputCapture` to keep the lines either way | | `redirectOutput` | Hands each line to `StdStream -> string -> unit` as it arrives, from both streams' reader threads | | `noPrefixForStep` | Stops each step's output being prefixed with its stage and step index | | `noStdRedirectForStep` | Stops redirecting the child's stdout/stderr — the mechanism every output operation above depends on — and overrides all of them | | `shuffleExecuteSequence` | Randomises step order at each run | | `verbosity` | How much of the pipeline's own log this stage prints. Takes `Verbosity.Quiet`, `Normal` or `Verbose` | | `verbose` / `quiet` | `verbosity Verbose` and `verbosity Quiet` | A stage nested inside another is one step of its parent. Stages nest to any depth. A block is a value that `stage`, `pipeline` or `command` can yield. ### Running a command from inside a step `run` and `runSensitive` cover the case where a command's exit code is the whole result. A step built with `run (fun ctx -> ...)` reaches for one of these `Operation<'T>` functions when it needs the command's output as a value: | Function | What it does | |---|---| | `execute cmd` | Runs `cmd`, streaming its output through the stage's own output routing. Fails on an exit code the stage does not accept; carries no captured text | | `executeCapture cmd` | Runs `cmd`, capturing stdout and stderr instead of streaming them. Fails on an unaccepted exit code the same way `execute` does, with the captured `CommandResult` attached to the failure as evidence | | `attemptCapture cmd` | Runs `cmd`, capturing stdout and stderr, and always answers the `CommandResult` — an unaccepted exit code included. A process-start failure and a cancellation remain outcomes of their own, never a `CommandResult`; only a process that ran to completion produces one, and branching on its exit code is the caller's | Captured stdout and stderr are the child's raw bytes, ahead of any prefix or display formatting — application data that can hold secrets. Printing a successful capture is the caller's decision. Neither `executeCapture` nor `attemptCapture` does it automatically. ## Pipeline operations Available inside `pipeline "name" { }` and inside `Command.pipeline { }`, which takes the name and description of the command that runs it. | Operation | What it does | |---|---| | `description` | The pipeline's description. Discarded in `Command.pipeline { }`, which always takes the command's own name and description instead | | `timeout` | Cancels the whole pipeline after the given duration | | `timeoutForStage` | The default `timeout` of each stage | | `timeoutForStep` | The default `timeoutForStep` of each stage | | `workingDir` | The default working directory of every stage. Takes a `string` or a `DirectoryInfo` | | `envVars` | Environment variables every stage inherits. Appends to the pipeline's map rather than replacing it | | `acceptExitCodes` | The exit codes that count as success. Replaces the default `[0]` | | `outputTo` | The default output sink of every stage | | `silentOutput` | Drops every stage's step output | | `captureOutput` | Holds every stage's step output back, lifting it into the error message on failure | | `redirectOutput` | Hands every line of step output to `StdStream -> string -> unit` | | `noPrefixForStep` | Stops step output being prefixed with the stage and step index | | `noStdRedirectForStep` | Stops redirecting child stdout/stderr | | `runBeforeEachStage` | A `StageContext -> unit` hook run before each stage. Replaces the previous hook | | `runAfterEachStage` | A `StageContext -> unit` hook run after each stage. Replaces the previous hook | | `post` | The stages that run after the main stages whether or not the pipeline succeeded — the teardown slot. Replaces any post stages already declared | | `verbosity` | How much the pipeline prints. Takes `Verbosity.Quiet`, `Normal` or `Verbose` | | `verbose` / `quiet` | `verbosity Verbose` and `verbosity Quiet` | | `onFailure` | Registers a handler that runs once per failed run, after the handlers of every stage of that run. See [Failure handlers](#failure-handlers) | `command`/`rootCommand` carries no `onFailure` default. Such a default would have to cover a failure of `InputSpec.Read` or of CLI parsing, ahead of every pipeline it runs. Nothing observes that failure today. ## Producers and dependencies A `Producer<'T>` is a typed, named unit of deferred work with its own CLI inputs and its own prerequisites. Declaring one registers its identity and harvests those inputs. Nothing runs until a consumer schedules it. | Function | What it does | |---|---| | `Producer.define name inputs dependencies execute` | Declares a producer: its own `InputSpec<'I>`, a `DependencySpec<'D>` of prerequisites, and the work computing `'T` from both | | `Producer.stage` | Places a producer at this exact point of a pipeline or parent stage, rather than leaving its placement implicit | | `Producer.emptyDefine name execute` | Declares a producer with no inputs or dependencies; identifies the operation as one that should only be run once. | | `DependencySpec.empty` | A specification with no prerequisites | | `DependencySpec.require producer` | A specification requiring one producer and reading its result | | `DependencySpec.map fn spec` | The prerequisites of `spec`, its value read through `fn` | | `DependencySpec.map2 fn first second` | The prerequisites and inputs of both specifications, unioned, their values read through `fn` | | `DependencySpec.zip first second` | A specification requiring both producers and reading their results as a pair | | `Stage.consuming name dependencies execute` | A stage whose one step is `execute` run over `dependencies`, with no CLI inputs of its own | | `Stage.consumingWith name inputs dependencies execute` | The same, plus CLI inputs the stage itself declares | A producer's handle carries an identity allocated when declared. Two declarations sharing a name and arguments are distinct producers with distinct results. Depending on the same handle from more than one consumer runs it once per invocation and shares that result. An unlisted producer required by a stage runs immediately before that stage, after its own prerequisites. Listing it explicitly (`Producer.stage`, or yielding it into a pipeline) fixes its position instead. A consumer running under a `parallel'` or `shuffleExecuteSequence` scope reads a value published before that scope began. Placing a producer inside such a scope fails validation, naming the producer and the scope. A required producer that is skipped or fails leaves its consumers skipped, carrying a dependency reason. An `Option`/`ValueOption` result models an intentional absence, distinct from a failed producer, and a consumer can handle it directly. Retrying a consumer through `retry` reuses the successful results of producers outside the retried scope. A producer owned by the retried scope gets a fresh result on each attempt, and a failed attempt leaves no value for the next one to read. ## Failure handlers `onFailure` registers a `FailureContext -> unit` handler on a `stage` or a `pipeline`. It runs once per failed execution of that scope, after the scope exhausts its `retry` attempts, and inner handlers run before outer ones — a stage's handler before the pipeline's. A recovered retry, or a cancelled stage, runs no handler. A stage's own `timeout` and `timeoutForStep` count as failures of that stage. A cancellation from an ancestor's token, the pipeline's, or the invocation's runs no handler. The handler reads `FailureContext.Primary`/`.Secondary` for the causes recorded, and `FailureContext.TryGetOutput producer` for a value the invocation has already published — answering `ValueNone` for a producer that has not run. An exception out of a handler becomes one more cause of the same scope. The original failure stays primary, and successful reporting preserves it. A failing handler triggers no second run of itself. Known limitations: - A handler is synchronous and unbounded, with no cleanup operation or budget separate from its own body. - `onFailure` has no equivalent on `command`/`rootCommand`. A failure of `InputSpec.Read` or of CLI parsing reaches no handler. - A stage with no `timeoutForStep` runs its steps under the attempt's own cancellation source, not a budget of its own. A step still in flight when its scope unwinds keeps its sources undisposed, rather than racing a straggler that may still read them. - `FailureCause.summarise`, used in `ScopeReports`, keeps only the first line of a multi-line capture. Later lines are lost from the report, not merely hidden from the rendering. - `OperationFailedException`'s message is written by hand for each `FailureCause` case rather than through `FailureCause.describe`, so the two can drift. - `whenStageSucceeds` (the body of `whenStage`) reads the policy-folded outcome of the condition stage: one carrying `continueStageOnFailure` reports itself as succeeded even where it failed. - The gate placing an unlisted producer immediately before a `whenStage` consumer evaluates that consumer's condition stage a second time, beyond `whenStage`'s own evaluation. - `PipelineContext.run`, called directly rather than through a command's own invocation, skips `DependencyPlan.validate`: an arrangement validation would reject — a producer inside a `parallel'` scope, say — runs instead of failing up front. ## Migrating work out of `InputSpec.Read` `InputSpec<'T>.Read` is a projection, `ParseResult -> 'T`, called once per invocation to bind the CLI values a stage declared. Effects belong in a step or a producer, not in `Read`: a `Read` that shells out or writes a file runs on every path that resolves inputs, `--help` and `--explain` included, since resolution happens ahead of the flag check. Before, doing the work inside `Read`: ```fsharp let publish = input { let! tag = Input.option "--tag" |> Input.def "v0.0.0" // Runs on every resolution of this input, --help and --explain included. let manifest = fetchManifest tag return stage "publish" { run (cmd $"deploy --version {manifest.Version}") } } ``` After, the same CLI option feeding a producer, and the stage consuming its typed result: ```fsharp let tag = Input.option "--tag" |> Input.def "v0.0.0" let manifest: Producer = Producer.define "manifest" (InputSpec.ofInput tag) DependencySpec.empty (fun tag () -> Operation.ofAsync (fetchManifestAsync tag)) let publish = pipeline "release" { stage "publish" { retry 2 onFailure (fun context -> context.TryGetOutput manifest |> ValueOption.iter (fun manifest -> printfn $"publish failed for {manifest.Version}")) consumes (DependencySpec.require manifest) (fun manifest -> execute (cmd $"deploy --version {manifest.Version}")) } } ``` `Read` now binds only the option. `--help` and `--explain` resolve it without running `fetchManifest` or `deploy`, since a producer runs only where its consumer is scheduled. The consumer's `retry` repeats the deploy alone: `manifest` is required, not retried, so a failing deploy re-reads the same published value rather than re-fetching it. Its `onFailure` reads that same value back out of the failure it is given. The CLI layer stays applicative: `tag` is still an ordinary `ActionInput`, readable without a `ParseResult`, exactly as before. ## Command operations Available inside `command "name" { }` and `rootCommand argv { }` / `rootCommandOfScript { }`, except for the three marked as root-only. | Operation | What it does | |---|---| | `description` | The command's description, shown in help | | `alias` / `aliases` | Alternative names for the command. These accumulate | | `hidden` | Keeps the command out of help output | | `addCommand` / `addCommands` | Adds subcommands. Yielding a `Command` value does the same | | `addInput` / `addInputs` | Registers an option or argument no pipeline asks for. Options a stage binds are registered already | | `timeout` | Pipeline default: the whole run. Takes `int` seconds or a `TimeSpan` | | `timeoutForStage` | Pipeline default: each stage. Takes `int` seconds or a `TimeSpan` | | `timeoutForStep` | Pipeline default: each step. Takes `int` seconds or a `TimeSpan` | | `workingDir` | Pipeline default: the directory commands run in | | `envVars` | Pipeline default, per key: a pipeline that sets one of these keys itself keeps its own value and the rest still apply | | `acceptExitCodes` | Pipeline default: the exit codes that count as success | | `outputTo` / `silentOutput` / `captureOutput` / `redirectOutput` | Pipeline default: where step output goes | | `noPrefixForStep` / `noStdRedirectForStep` | Pipeline default: prefixing and child stream redirection | | `runBeforeEachStage` / `runAfterEachStage` | Pipeline default: the per-stage hooks | | `post` | Pipeline default: the teardown stages | | `verbosity` / `verbose` / `quiet` | Pipeline default: how much the pipeline prints | | `name` | **Root only.** What the root command calls itself in help and usage. Defaults to the script's filename | | `parserConfiguration` | **Root only.** A `System.CommandLine` `ParserConfiguration` | | `invocationConfiguration` | **Root only.** A `System.CommandLine` `InvocationConfiguration` | A command yields stages directly — `command "test" { Stages.restore; Stages.test }` — and consecutive stages become one implicit pipeline carrying the command's name and description. `Command.pipeline { }` is that same pipeline written out, for when it needs pipeline-level settings. `pipeline "name" { }` is for several pipelines under one command, or a pipeline that needs its own name. ## Flags every command carries | Flag | What it does | |---|---| | `--explain` | Prints the resolved stage tree and runs nothing. A grouping command lists its subcommands | | `--json` | With `--explain`, prints the tree as JSON, leaving `whenBranch` and `whenStage` conditions unevaluated. On a run, prints the run result — each stage's outcome, failures and timing — as one line of JSON after the run, in place of the timing table | | `--report ` | Writes the run result as JSON to a file. Only on commands that run pipelines | | `--schema` | Prints the command, its options (name, aliases, type, default, accepted values, description) and its subcommands as JSON, and runs nothing | `--json` defaults to true in an agent environment, detected lazily from `AGENT`, `AI_AGENT` or vendor markers following [is-ai-agent's environment rules](https://github.com/sdairs/is-ai-agent#detection-order). Use `--json false` to select text explicitly, or `PARTAS_BUILD_DISABLE_AI=1` to disable automatic defaults. The override leaves explicit `--json` available. Detection is fresh per invocation in a long-lived host. Neither `--explain` nor `--schema` is enabled automatically, and consumer-declared options keep their defaults. A command that declares one of these names itself keeps its own option. Every JSON document carries `formatVersion`. Text `--explain` evaluates every condition, running a `whenStage` condition stage once; a condition that throws is shown with its message. `Conditions.effectful description condition` marks a condition of your own so the JSON form leaves it unevaluated too. ## Exit codes | Code | `ExitCode` / `RunOutcome` | When | |---|---|---| | `0` | `Success` / `Succeeded` | Every pipeline succeeded, or the invocation printed help, a version, `--explain` or `--schema` | | `1` | `Failure` / `Failed` | A stage failed, or the invocation raised an exception | | `2` | `UsageError` / `UsageError` | The command line did not parse or validate, or the selected pipelines failed dependency validation, `--explain` included. No stage ran | | `130` | `Cancelled` / `Cancelled` | The pipeline's own timeout expired, or the invocation's cancellation token fired | A stage's own `timeout` is a failure of that stage (`1`), not a cancellation. Ctrl+C terminates the process by signal: it writes no run result, `--json` line or `--report` file, and the exit status is the shell's (`130` on Unix by convention). ## Invoking a command from code `rootCommand argv { }` parses and runs as it is constructed, and answers the exit code. `Command.root { }` takes the same operations and builds a `RootCommandDefinition` without parsing or running anything, so a host binds it once and invokes it any number of times. [Hosting a build in a long-lived session](hosting.md) walks through it. | Member | What it does | |---|---| | `Command.root { }` | Builds a `RootCommandDefinition`: every operation `rootCommand` takes, nothing run | | `Command.invoke args root` | Parses `args` (without the script name), runs what they select, and answers a `RunResult` | | `root.Invoke(args, ?output, ?error, ?cancellationToken)` | The same, with a `TextWriter` for everything the run prints (help, `--explain`, the pipeline's lines and console-sink child processes, as plain text), a writer for parse errors, and a token that cancels the run | | `root.Command` | The underlying `System.CommandLine` `RootCommand` | `Invoke` runs on a thread of its own. `Thread.Interrupt` on the calling thread cancels the run as the token would — killing the process tree of any running step — then raises `ThreadInterruptedException` from `Invoke`. `RunResult` is `{ ExitCode; Outcome: RunOutcome; Pipelines: PipelineRun list }`, where each `PipelineRun` is `{ Name; Reports: ScopeReport list; Timings: StageTiming list }`, snapshots taken as the pipeline finished. Its members flatten across pipelines: `Reports`, `Timings` (pre-order) and `Failures` (every `StepFailure`, tolerated ones included). `RunResult.toJson indented result` is the document `--json` and `--report` write. ## Condition builders `whenAll { }`, `whenAny { }` and `whenNot { }` take these. Each yields a single condition to a stage. An empty `whenAll`/`whenNot` is always active. An empty `whenAny` never is. | Operation | What it does | |---|---| | `when'` | A `bool`, or a `StageContext` that must succeed | | `envVar` | An environment variable by name, by name and value, or as an `EnvArg` | | `branch` / `branches` | The current git branch | | `platformWindows` / `platformLinux` / `platformOSX` | The running platform. Pass `false` to invert | | `platform` | The same over an `OSPlatform` value | `whenEnv { }` describes one environment variable in place of a wall of overloads: `name`, `description`, `value`, `acceptValues` and `optional`. `whenStage "name" { }` runs a stage for its result — everything `stage` accepts is accepted, and the stage runs for real, side effects included. `whenSome value build` and `whenOk value build` are functions rather than operations. Each returns a `StageContext list`: the stage built from the bound value, or `[]`. The absent case is an empty list, not an inactive stage requiring a name. `build` receives the value already unwrapped, inside the condition that guards it. ## `Input` combinators Declaring functions: | Function | What it makes | |-----------------------------------------------------------|---| | `Input.option<'T> "--name"` | An option bound as `'T` | | `Input.optionMaybe<'T> "--name"` | An option bound as `'T option`, `None` when absent | | `Input.argument<'T> "name"` | A positional argument bound as `'T` | | `Input.argumentMaybe<'T> "name"` | A positional argument bound as `'T option` | | `Input.context` | Injects the `ActionContext` — the `ParseResult` and a cancellation token | | `Input.inject value` | Injects a value that is not parsed from the command line | | `Input.ofOption` / `Input.ofArgument` | Lifts a raw `System.CommandLine` `Option<'T>` / `Argument<'T>` | Shaping combinators, all `ActionInput<'T> -> ActionInput<'T>` and all pipeable: | Function | What it does | |---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------| | `Input.alias` / `Input.aliases` | Adds alternative names. Options only | | `Input.description` (`Input.desc` is obsolete) | The help text | | `Input.helpName` | The value placeholder in help — `` | | `Input.defaultValue`, `Input.def` | The value used when the token is absent | | `Input.defaultValueFactory` | The same, computed from the `ArgumentResult` | | `Input.arity` | How many values are accepted: `ExactlyOne`, `OneOrMore`, `Zero`, `ZeroOrMore`, `ZeroOrOne`, or `ArgumentArity (min, max)` | | `Input.required` | Marks an option required | | `Input.recursive` | Applies the option to the command and, recursively, its subcommands | | `Input.hidden` | Keeps it out of help output | | `Input.sensitive` | Marks the symbol secret: `--schema` reports it `"sensitive": true` and writes a present default as `"***"` | | `Input.allowMultipleArgumentsPerToken` | Lets one identifier token carry several values | | `Input.acceptOnlyFromAmong` | Restricts to a set of legal strings, ordinally | | `Input.addCompletion` / `Input.addCompletions` | Adds tab-completion suggestions without restricting what is accepted | | `Input.mapFromAmong<'T> [ "key", value ]` | An option over a known set, each key bound to a typed value | | `Input.mapFromAmongWith<'T> comparer` | `mapFromAmong` under an explicit `StringComparer` | | `Input.mapFromMany` / `mapFromManyWith` | The repeatable forms, binding `'T list` | | `Input.acceptLegalFileNamesOnly` / `Input.acceptLegalFilePathsOnly` | Restricts to legal file names / paths | | `Input.validate` | A `'T -> Result` check; `Error` becomes a CLI validation message | | `Input.validateFileExists` / `Input.validateDirectoryExists` | The two common cases, over `FileInfo` / `DirectoryInfo` | | `Input.addValidator` | A raw `SymbolResult -> unit` validator | | `Input.customParser` | An `ArgumentResult -> 'T` parser | | `Input.tryParse` | An `ArgumentResult -> Result<'T, string>` parser; `Error` becomes a parse diagnostic instead of an exception | | `Input.editOption` / `Input.editArgument` | Reaches the underlying `Option<'T>` / `Argument<'T>` for anything not covered above | ## `InputSpec<'T>` `InputSpec<'T>` is public at `Partas.Build`. A stage factory parameterised by an option needs no `open Partas.Build.Internal`: ```fsharp let build (projects: InputSpec) = input { let! projects = projects and! config = Options.config ... } ``` | Function | What it does | |---|---| | `InputSpec.ofInput` | Lifts an `ActionInput<'T>` into a spec | | `InputSpec.ret` | A spec that reads nothing and returns a constant | | `InputSpec.map` | Reshapes the value a spec reads | | `InputSpec.map2` | Combines two specs, unioning their inputs | | `InputSpec.sequence` | A list of specs into one spec of a list | | `InputSpec.traverse` | `sequence` over the results of a mapping | | `InputSpec.union` | Concatenates input lists, keeping the first occurrence of each | The `input { let! … and! … return … }` CE is the usual way to build one; `inputs` is the same builder under an obsolete second name (`src/Partas.Build/Builders/Inputs.fs` binds both). It is applicative: bind every source in a single `let!`/`and!` group. A sequential second `let!` is a compile error (`FS0708`): the input set must be readable before anything is parsed. An `input { }` nested inside another's `return` produces an `InputSpec>`, which nothing accepts — pass the *source* in as an `InputSpec` instead. ## `Cmd` A `Cmd` keeps the executable and its arguments apart all the way to `ProcessStartInfo.ArgumentList`, so the platform does the escaping. | Function | What it does | |---|---| | `cmd $"dotnet build {project}"` | Each hole becomes exactly one argument, whatever it contains. `run $"..."` binds to the `string` overload and flattens the holes, so interpolate through `cmd` | | `Cmd.ofString` | Splits a whole command line, honouring `"` and `'` | | `Cmd.create exe args` | The executable exactly as given, plus an argument string split as `ofString` does | | `Cmd.ofList exe args` | Both exactly as given | | `Cmd.arg` / `Cmd.args` | Appends arguments exactly as given | | `Cmd.argIf cond values` | Appends only when `cond` holds — one line instead of two whole command lines under an `if` | | `Cmd.argWhenSome value render` | Appends the arguments rendered from a `Some`, and nothing from a `None` | | `Cmd.secretArg` | Appends one argument whose value is masked wherever the command is printed | | `Cmd.secretOption flag value` | Appends a visible flag and a masked value: `-k ***` | | `Cmd.secretOptionWhenSome flag value` | The same when the value exists, appending nothing otherwise | | `Cmd.secret` / `Cmd.sensitive` | Marks a string unprintable before it goes into a `cmd` hole | | `Cmd.ofFormattable secret` | The interpolation reader behind `cmd` and `runSensitive` | | `Cmd.toLogString` | How the command prints: secrets masked, whitespace-carrying arguments quoted | ## `Args` The arguments a script was given, as distinct from the ones its host was given. | Function | What it answers | |---|---| | `Args.script ()` | The running script's own arguments. `rootCommandOfScript { }` is `rootCommand (Args.script ()) { }` | | `Args.scriptName ()` | The running script's filename, when it was launched as one | | `Args.afterScript argv` | Everything after the `.fsx` in `argv`, or after `argv[0]` when there is none. A leading `--` is dropped | | `Args.take argv` | Everything after the first `--` | | `Args.nameOf argv` | The filename of the first `.fsx` in `argv` | `dotnet fsi build.fsx -- test --quick` does not reach the process with its `--` intact: the `dotnet` driver consumes one before `fsi` sees the command line. `Args.script` locates the script's own filename instead of splitting on a separator. ## `Baked` Ready-made declarations for the options every build CLI ends up wanting. They ship in their own package, `Partas.Build.Baked`. Each declaration is a `BuildOption<'T>` carrying both forms: `.option` is the flag, `.argument` the positional equivalent. `BuildOption.map`, `.mapOpt` and `.mapArg` apply an `Input.*` combinator to both forms or to one. | Value | What it declares | |---|---| | `Baked.NuGet.apiKey` | `nuget-key` (aliases `--nuget`, `-k`) as `string option`, defaulting to the `NUGET_API_KEY` environment variable | | `Baked.Dotnet.config` | `configuration` (alias `-c`) as `string option`, over `release`/`r`/`debug`/`d` case-insensitively | | `Baked.SemVer.bump` | `bump` as `Bump option`, over `major\|minor\|patch\|alpha\|beta\|rc\|preview\|`, defaulting to `Patch` | | `Baked.Common.isCI` | `--ci`, defaulting to true when any of the usual CI environment variables is set | | `Baked.Common.quick` | `--quick` (alias `-q`): skips restores, installations, cleaning and formatting | | `Baked.Common.skipTests` | `--skip-tests` | | `Baked.Common.watch` | `--watch` | | `Baked.Dotnet.configOrRelease` | `InputSpec`: the `--configuration` value, `Release` when it is omitted | `Baked.Stages` holds ready-made stages, each an `InputSpec` that `stage`, `pipeline` and `command` yield directly and whose options reach the command's `--help`. Each reads Baked's own options; its `…With` counterpart takes each of those options as an `InputSpec<_>` instead. A skip reports its reason to `--explain`. | Function | What it does | |---|---| | `Stages.restore solution` | `dotnet tool restore`, then `dotnet restore`; skipped by `--quick` | | `Stages.clean directories files` | Empties the directories and deletes the files the glob patterns select (`!` excludes), under the stage's working directory, following no links; skipped by `--quick` | | `Stages.build projects` | One `dotnet build -c ` sub-stage per project, in parallel | | `Stages.pack outDir projects` | One `dotnet pack --no-build --no-restore` sub-stage per project, in parallel | | `Stages.expecto project arguments` | Runs a built Expecto suite through `dotnet run --no-build`; skipped by `--skip-tests`; under `--ci`, passes `--summary` and captures the output | | `Stages.nugetPush packages` | `dotnet nuget push --skip-duplicate`, to nuget.org with `--nuget-key` (masked everywhere it prints) or to the source named `local` without one | | `Stages.fantomas paths` | `dotnet fantomas` over the paths; skipped by `--quick` | | `Stages.npmInstall directory` | `npm ci` under `--ci`, `npm install` otherwise; skipped by `--quick` | | Function | What it does | |---|---| | `Baked.SemVer.Version.apply bump version` | Semantic version arithmetic over a `Bump` | | `Baked.SemVer.Version.assembly version` | The assembly version that goes with a package version: its major, and nothing else | | `Baked.SemVer.Version.IO.writeVersion` / `setVersion` | Rewrites `` and `` in a project file | | `Baked.SemVer.Version.IO.bumpVersion projPath bump` | Applies a bump to a project file in place, answering the versions before and after | | `Baked.SemVer.Stages.bumpArgument projects` | A `bump` stage taking the bump kind as a positional argument | | `Baked.SemVer.Stages.bumpOption projects` | The same with the bump kind as `--bump` | ## Reference - [Overview](index.md) — the layers, and a first pipeline. - [Composing reusable blocks](composition.md) — blocks, nesting, and composition across files. - [Hosting a build in a long-lived session](hosting.md) — `Command.invoke` from SageFs or any F# host. - [Stage CE run overloads](computation-expression-operations.md). - [API reference](https://shayanhabibi.github.io/Partas.Build/reference/) — full signatures and remarks. --- # Installation > Add Partas.Build to a project or an F# script. Packages are published to the [Partas.Build Cloudsmith feed](https://cloudsmith.io/~shayanhabibi/repos/shayanhabibi-partas-build/packages/). ## Build project Add the source, then the packages you need: ```shell dotnet nuget add source https://nuget.cloudsmith.io/shayanhabibi/shayanhabibi-partas-build/v3/index.json --name partas-build dotnet add Build.fsproj package Partas.Build dotnet add Build.fsproj package Partas.Build.Baked ``` `Partas.Build` is the DSL and execution engine. `Partas.Build.Baked` supplies common build inputs and stages. The [ShipIt extension](../extensions/shipit.md) is optional. ## F# script Reference the feed explicitly: ```fsharp #i "nuget: https://nuget.cloudsmith.io/shayanhabibi/shayanhabibi-partas-build/v3/index.json" #r "nuget: Partas.Build, 0.8.0" #r "nuget: Partas.Build.Baked, 0.2.0" open Partas.Build ``` Pin versions for repeatable builds. Add `Partas.Build.EasyBuild.ShipIt, 0.1.0` if you need release workflows. ## Frameworks The build packages target `net10.0`, `net8.0`, and `netstandard2.0`. Use an SDK compatible with your build project's target framework. External tools have their own runtime requirements; the default ShipIt tool requires .NET 10. Continue with [Getting started](getting-started.md). --- # Getting started > Create a build command with a configuration option and generated help. Create a build project or script using the [installation guide](installation.md). ## Define a stage ```fsharp open Partas.Build let compile = input { let! config = Input.option "--configuration" |> Input.alias "-c" |> Input.def "Release" |> Input.acceptOnlyFromAmong [ "Debug"; "Release" ] return stage "compile" { run (cmd $"dotnet build -c {config}") } } ``` The stage owns `--configuration`. `cmd` keeps each interpolated value as one argument. ## Add it to a root ```fsharp let root = Command.root { description "My build" workingDir __SOURCE_DIRECTORY__ command "build" { description "Compile the solution" compile } } let build args = Command.invoke args root ``` `Command.root` constructs the command tree without executing it. Relative process paths resolve against `workingDir`. ## Invoke it For an executable project, place this entry point after the definitions: ```fsharp [] let main argv = (build argv).ExitCode ``` ```shell dotnet run --project Build.fsproj -- build --help dotnet run --project Build.fsproj -- build --explain dotnet run --project Build.fsproj -- build -c Debug ``` For a script, use this final line instead: ```fsharp exit (build (Args.script ())).ExitCode ``` ```shell dotnet fsi build.fsx -- build --help ``` `--help` lists the stage's configuration option automatically. `--explain` prints the workflow without running the build. Exit code `0` means success, `1` a stage failed, `2` invalid input, and `130` cancellation. Next: [bind more inputs](inputs.md), [compose stages](composition.md), or use [Baked's .NET stages](baked.md). --- # Inputs and help > Bind CLI options and arguments where they are used. Declare an input once. Bind it in an `input` block that returns a stage or pipeline. ```fsharp open Partas.Build let quick = Input.option "--quick" |> Input.alias "-q" |> Input.description "Skip restore" let configuration = Input.option "--configuration" |> Input.def "Release" let compile = input { let! skipRestore = quick and! config = configuration return pipeline "compile" { stage "restore" { when' (not skipRestore) "--quick is set" run "dotnet restore" } stage "build" { run (cmd $"dotnet build -c {config}") } } } let root = Command.root { command "build" { compile } } ``` Both options appear in `build --help`. Another command receives them only if its stages bind them. ## Bind independent inputs together Use one `let!` followed by `and!` for additional inputs. A second `let!` is rejected. The complete input set must be known before parsing. If one value depends on another, derive it after binding. Keep runtime work inside a stage, rather than in the input reader. ## Choose the input shape - `Input.option<'T> "--name"`: an option. - `Input.argument<'T> "NAME"`: a positional argument. - `Input.def value`: a default. - `Input.acceptOnlyFromAmong values`: validation against a fixed set. - `Input.map f`: transform a parsed value. - `Input.sensitive`: hide the value in structured inspection. See [Capabilities](CAPABILITIES.md#input-combinators) for the complete list and [Baked](baked.md) for ready-made inputs. --- # Computation Expression Operations ## Stage > Unless stated otherwise, every example runs inside a `stage` computation. `run` takes a step and has more overloads than any other operation, which is why it gets its own page. ### `run` > A returned `string`-like value runs as a command. > > A returned `int`-like value is an exit code. > > An overload that returns or runs a command usually takes an optional `?cancellationToken: CancellationToken`. ##### `buildStep: StageContext -> BuildStep` ##### `command: string -> ?cancellationToken: CancellationToken` ```fsharp run "exe args --options" run "exe args --options" CancellationToken.None ``` ##### `exe: string -> args: string -> ?cancellationToken: CancellationToken` ```fsharp run "exe" "args --options" run "exe" "args --options" CancellationToken.None ``` ##### `command: Cmd -> ?cancellationToken: CancellationToken` ```fsharp run (cmd $"exe args --options") run (cmd $"exe args --options") CancellationToken.None ``` ##### `asyncExitCode: Async` ```fsharp run (async { return 0 }) ``` ##### `asyncAction: Async` ```fsharp run (async { do () }) ``` ##### `exitCodeFn: StageContext -> int` ##### `exitCodeFn: StageContext -> Async` ##### `exitCodeFn: StageContext -> Task` ```fsharp run (fun _ -> 1) run (fun _ -> async { return 0 }) run (fun _ -> task { return 99 }) ``` ##### `actionFn: StageContext -> unit` ##### `actionFn: StageContext -> Async` ##### `actionFn: StageContext -> Task` ```fsharp run (fun _ -> ()) run (fun _ -> async { do () }) run (fun _ -> task { do () }) ``` #### `runLine` A function returning a command line is `runLine`, not `run`. `run (fun ctx -> "...")` still compiles, marked obsolete: a lambda written to return a message would otherwise start a process. The line is split on whitespace, honouring quotes; build a `Cmd` with `cmd $"..."` to keep each interpolation hole as one argument. ##### `commandFn: StageContext -> string` ##### `commandFn: StageContext -> Async` ##### `commandFn: StageContext -> Task` ```fsharp runLine (fun _ -> "dotnet build") runLine (fun _ -> async { return "dotnet build" }) runLine (fun _ -> task { return "dotnet build" }) // With CancellationToken runLine (fun _ -> "dotnet build") CancellationToken.None runLine (fun _ -> async { return "dotnet build" }) CancellationToken.None runLine (fun _ -> task { return "dotnet build" }) CancellationToken.None ``` ##### `commandMaybeFn: StageContext -> string option` ##### `commandMaybeFn: StageContext -> Async` ##### `commandMaybeFn: StageContext -> Task` ```fsharp runLine (fun _ -> Some "dotnet build") runLine (fun _ -> async { return Option.None }) runLine (fun _ -> task { return Some "dotnet build" }) // With CancellationToken runLine (fun _ -> Some "dotnet build") CancellationToken.None runLine (fun _ -> async { return Some "dotnet build" }) CancellationToken.None runLine (fun _ -> task { return Some "dotnet build" }) CancellationToken.None ``` ##### `resultFn: StageContext -> Result` ##### `resultFn: StageContext -> Async>` ##### `resultFn: StageContext -> Task>` ```fsharp run (fun _ -> Error "some error") run (fun _ -> async { return Ok() }) run (fun _ -> task { return Error "some error" }) ``` ##### `cmdResultFn: StageContext -> Result` ##### `cmdResultFn: StageContext -> Async>` ##### `cmdResultFn: StageContext -> Task>` ```fsharp // todo - overloads without CancellationToken should not require explicit typing run (fun _ -> Ok (Some (cmd $"dotnet build")) : Result) run (fun _ -> async { return Ok (Some (cmd $"dotnet build")) : Result }) // With CancellationToken run (fun _ -> Ok (Some (cmd $"dotnet build"))) CancellationToken.None run (fun _ -> async { return Ok (Some (cmd $"dotnet build")) }) CancellationToken.None ``` ##### `cmdResultFn: StageContext -> Result` ##### `cmdResultFn: StageContext -> Async>` --- # Processes and secrets > Run processes and F# functions while keeping arguments and secrets intact. ## Preserve arguments Use `cmd` for interpolated process commands. Each hole becomes one argument, including paths with spaces. ```fsharp open Partas.Build let project = "src/My Library/My Library.fsproj" let compile = stage "compile" { run (cmd $"dotnet build {project}") } ``` Plain `run "dotnet build"` is suitable for a fixed command. `run $"dotnet build {project}"` flattens the interpolation into a string and can split a path into several arguments. ## Mask secrets `runSensitive` accepts an interpolation directly and masks every hole in the command label: ```fsharp let token = "example-token" let publish = stage "publish" { runSensitive $"dotnet nuget push package.nupkg --api-key {token}" } ``` For selective masking, construct a `Cmd` and mark only the secret arguments. Masking command labels does not redact output printed by the child process. ## Run F# work ```fsharp let report = stage "report" { run (fun (ctx: StageContext) -> printfn "Stage: %s" ctx.Name) } ``` Use `echo` or `StageContext.writeLine` when output must follow the stage's configured writer. A bare `printfn` writes directly to the process console. ## Route process output Stages can inherit or override an output sink: ```fsharp let quietBuild = stage "compile" { silentOutput run "dotnet build" } ``` `silentOutput` controls child output; `quiet` controls pipeline logging. `captureOutput`, `teeOutput`, `outputTo`, and `OutputCapture` support collection and routing. See the [workflow reference](workflow-reference.md#where-step-output-goes) for examples. --- # Composition > Reuse stages, assemble workflows, and share command trees across files. Stages and pipelines are F# values. A reusable block can return a plain stage or an `InputSpec` when it needs CLI inputs. ## Define a block ```fsharp open Partas.Build let configuration = Input.option "--configuration" |> Input.def "Release" let compile project = input { let! config = configuration return stage $"compile {project}" { run (cmd $"dotnet build {project} -c {config}") } } ``` ## Assemble a workflow ```fsharp let projects = [ "src/Core/Core.fsproj"; "src/App/App.fsproj" ] let build = pipeline "build" { workingDir __SOURCE_DIRECTORY__ stage "restore" { run "dotnet restore" } for project in projects do compile project } let root = Command.root { command "build" { build } } ``` `build --help` registers `--configuration` once, even though several stages read it. The same block can be reused by another command. ## Nest stages ```fsharp let libraries = stage "libraries" { timeout 300 for project in projects do compile project } ``` Place settings before loops. For individually yielded blocks, settings can appear before or after the block. Children inherit unset settings from the enclosing stage. ## Wrap blocks that carry inputs A wrapper returning a stage can accept an input-bearing stage: ```fsharp let group name (block: InputSpec) = stage name { block } let grouped = group "core" (compile "src/Core/Core.fsproj") ``` The wrapper retains the inner input set. See the [composition reference](composition-reference.md#blocks-that-take-blocks) for wrappers accepting lists, adding their own inputs, and compiler limits around nested specs. ## Yield lists ```fsharp let blocks = [ for project in projects -> compile project ] let listed = pipeline "compile" { [ yield! blocks ] } ``` Use a list when F# rejects `yield!` mixed with custom operations. A `for` loop is usually the simpler form. ## Composition across files Put reusable definitions in a file that constructs commands without invoking or exiting: ```fsharp // tools/wire.defs.fsx module Wire open Partas.Build let generate = command "wire" { stage "generate" { echo "generating" } } ``` Load it from your entry-point script and add the command to the root: ```fsharp // build.fsx; the package references precede this #load #load "tools/wire.defs.fsx" open Partas.Build let root = Command.root { addCommand Wire.generate } exit (Command.invoke (Args.script ()) root).ExitCode ``` Keep command names unique among siblings. An explicit module name avoids guessing the identifier F# derives from a filename. For a reusable root shared with a session, follow [Hosting](hosting.md). The [composition reference](composition-reference.md) includes larger examples and standalone script entry points. --- # Hosting a build in a long-lived session A build script run as `dotnet fsi build.fsx -- test` compiles the script, resolves its packages and starts a runtime on every run, and every tool script it shells out to (`run "dotnet fsi tools/gen.fsx -- …"`) pays the same again. A long-lived F# session — [SageFs](https://github.com/WillEhrendreich/SageFs), or any host holding an `FsiEvaluationSession` — pays it once. The build then becomes a function the session calls, answering a structured `RunResult` instead of an exit code and a screen of text. This page is written against SageFs, but nothing in it depends on SageFs beyond the location of its startup script. ## The shape Three files, where a plain script build has one: | File | Holds | Side effects when loaded | |---|---|---| | `build.defs.fsx` | Options, stages, commands, and `let root = Command.root { … }` | None | | `build.fsx` | `#load "build.defs.fsx"`, then one line that invokes `root` and exits | Runs the build, exits the process | | `.SageFs/init.fsx` | `#load "build.defs.fsx"`, then a `build` function over `root` | None | The split exists because a session must never evaluate the last line of `build.fsx`. `rootCommand argv { … }` and `rootCommandOfScript { … }` run the build *as they are constructed*, and `exit` terminates the process — in a session, the session's own worker. `Command.root { … }` takes the same operations and builds the command without parsing or running anything. ### `build.defs.fsx` ```fsharp module BuildDefs #r "nuget: Partas.Build" #r "nuget: Partas.Build.Baked" open Partas.Build open Partas.Build.Baked let projects = [ "src/MyLib/MyLib.fsproj" ] let root = Command.root { description "MyLib's build" workingDir __SOURCE_DIRECTORY__ command "build" { description "Restores and builds" Command.pipeline { Stages.restore "MyLib.slnx" Stages.build projects } } command "test" { description "Builds and runs the tests" Command.pipeline { Stages.restore "MyLib.slnx" Stages.build projects Stages.expecto "tests/MyLib.Tests/MyLib.Tests.fsproj" [ "--sequenced" ] } } } ``` `workingDir __SOURCE_DIRECTORY__` makes every relative path resolve against the repository, whatever the session's current directory is. The explicit `module BuildDefs` gives the file a name that reads well from the other two; without it, the module is named after the file. ### `build.fsx` ```fsharp #load "build.defs.fsx" open Partas.Build exit (Command.invoke (Args.script ()) BuildDefs.root).ExitCode ``` `dotnet fsi build.fsx -- test --quick` behaves exactly as a single-file build did: same commands, same `--help`, same exit codes. ### `.SageFs/init.fsx` ```fsharp #load "build.defs.fsx" open System open Partas.Build let build (args: string list) = BuildDefs.root.Invoke(args, output = Console.Out) ``` SageFs evaluates the *text* of this file at the end of a session's warm-up, so `#load` paths resolve against the session's working directory — the repository — rather than against `.SageFs/`. `output = Console.Out` is read on each call, which in SageFs is the writer capturing the current evaluation. Given a writer, `Invoke` sends everything the run prints to it as plain text: help, `--explain`, the pipeline's own lines, and the output of every child process whose stage writes to the console. Without it, a child process inherits the worker's real standard output, and its lines never reach the evaluation's result. ## Calling it From the session, or from an agent through SageFs's `send_fsharp_code`: ```fsharp build [ "test"; "--explain" ] // what would run, and what is skipped and why let result = build [ "test"; "--quick" ] result.Outcome // Succeeded | Failed | UsageError | Cancelled result.ExitCode // 0 | 1 | 2 | 130 [ for failure in result.Failures -> failure.Label, FailureCause.describe failure.Cause ] [ for timing in result.Timings -> timing.Name, timing.Elapsed ] ``` `build [ "test"; "--json" ]` ends its output with the JSON form of the same result, for a caller that reads text rather than F# values. ## Mounting other scripts' commands A build that runs a tool script as a child process — ```fsharp stage "wire" { run "dotnet fsi tools/wire.fsx -- generate" } ``` — cold-starts `fsi` for it on every run, and loses the tool's result to an exit code. Split the tool the same way and mount its command instead: ```fsharp // tools/wire.defs.fsx module Wire #r "nuget: Partas.Build" open Partas.Build let generate = command "wire" { description "Regenerates the wire types" stage "generate" { run (cmd $"dotnet run --project tools/WireGen") } } ``` ```fsharp // build.defs.fsx #load "tools/wire.defs.fsx" let root = Command.root { // … addCommand Wire.generate } ``` `build [ "wire" ]` now runs in the session, ` --help` lists `wire` beside the build's own commands, and ` wire --help` lists the tool's options. The tool keeps a thin `tools/wire.fsx` of its own for standalone use. A tool that exposes its stages rather than a whole command composes further: yield them into a pipeline of the build's own. ## Cancelling a run SageFs's `cancel_eval` interrupts the thread running the evaluation (`Thread.Interrupt`); it signals no `CancellationToken`. `Invoke` runs the build on a thread of its own and waits for it, so the interrupt reaches the waiting thread, which: 1. cancels the run as a cancellation token would — the pipeline stops, and the whole process tree of every running command is killed, grandchildren included; 2. waits up to five seconds for the run to wind down; 3. raises `ThreadInterruptedException`, which ends the evaluation. No `RunResult` is returned. A caller that owns a token passes it instead: `BuildDefs.root.Invoke(args, output = Console.Out, cancellationToken = token)`, and a cancelled run returns a `RunResult` with `Outcome = Cancelled`. What cancellation does not reach: - **A step written as a plain F# function** — `run (fun ctx -> …)` — runs on until it returns, unless it reads the token itself (`Async.CancellationToken` inside an `async` step). An `async` step is stopped at its next asynchronous wait. A blocking function keeps its thread after the evaluation has ended. - **Work already done.** Cancellation stops what is running; it undoes nothing. ## What stays shared across calls A session keeps every value it evaluates. For a build, that means: - **`root` is built once.** A pipeline written as `Command.pipeline { … }` or `pipeline "…" { … }` with no CLI inputs is one value, reused by every call. Such a value runs one run at a time: a second call reaching it while a first is still running — including a straggler step from an interrupted run — fails with exit code `1` and "Pipeline '…' is already running". Stages yielded straight into a `command`, and pipelines built inside an `input { }`, are built afresh for each call. - **Option defaults are computed once**, when the module declaring them initialises. `Baked.Common.isCI` reads the CI environment variables and `Baked.NuGet.apiKey` reads `NUGET_API_KEY` at that moment, and keep those values for the life of the worker. Pass the flag explicitly, or restart the session (`hard_reset_fsi_session`), after changing them. The environment a *stage* runs with is read afresh at the start of every run. - **Editing `build.defs.fsx`** and re-sending its `#load` defines a new `BuildDefs` module; the `build` function bound earlier still calls the old `root`. Re-send the whole of `.SageFs/init.fsx` instead. The library keeps no other state between runs. It sets the console encodings at most once per process, and only for a stream that is not redirected, and its console follows whichever writer `Console.Out` is when it writes. Without a terminal — a SageFs worker has none — tables are laid out 80 columns wide. ## Live testing SageFs discovers Expecto tests in a loaded project and runs them as code changes, with a five-second default timeout per test. It classifies a test whose full name contains `integration` as an integration test, run only on demand. A build's own test suites that start processes — as this repository's do — belong there: ```fsharp [] let tests = testList "cmd" [ // … ] |> testLabel "integration" ``` --- # Execution > Control conditions, concurrency, timeouts, cleanup, and failures. ## Conditions narrow; settings override Every condition on a stage must pass. Use one `whenAny` block when either condition should enable it: ```fsharp open Partas.Build let publish = stage "publish" { whenBranch "master" whenWindows run "dotnet pack" } ``` This stage runs on Windows **and** master. A second condition never replaces the first. Supply a reason to `when'` so `--explain` can describe a skip. Settings such as `workingDir` and `parallel'` use the last value at the same level. A child stage inherits unset settings from its parents and pipeline. Command settings fill in pipeline defaults. ## Run independent work concurrently ```fsharp let compile = stage "compile" { parallel' 2 run "dotnet build A.fsproj" run "dotnet build B.fsproj" run "dotnet build C.fsproj" } ``` At most two steps run at once. Omit `parallel'` for sequential execution; use `parallel' true` for unbounded concurrency. Avoid parallel builds that write the same referenced project's outputs. ## Bound execution time ```fsharp let test = stage "test" { timeout 60 timeoutForStep 30 run "dotnet test" } ``` A timeout cancels the stage and kills its process tree. Pipelines additionally support `timeoutForStage`. A blocking F# function must cooperate with cancellation itself. ## Always clean up ```fsharp let integration = pipeline "integration" { stage "start" { run "docker compose up -d" } stage "test" { run "dotnet test" } post [ stage "stop" { run "docker compose down" } ] } ``` Post stages run after the main stages, including after failure. ## Decide how failure propagates - `continueStepsOnFailure`: run the remaining steps. - `continueStageOnFailure`: let the pipeline continue. - `continueOnStepFailure`: apply both. - `acceptExitCodes`: accept additional process exit codes. - `failIfIgnored`: treat a skipped stage as failure. For typed outputs and prerequisite ordering, use [producers and dependencies](CAPABILITIES.md#producers-and-dependencies). For recovery policies, see [failure handlers](CAPABILITIES.md#failure-handlers). --- # Baked stages > Reuse common .NET build operations and inputs. `Partas.Build.Baked` provides ready-made stages for restore, build, clean, pack, publishing, and tests. ```fsharp open Partas.Build open Partas.Build.Baked let projects = [ "src/MyLibrary/MyLibrary.fsproj" ] let root = Command.root { workingDir __SOURCE_DIRECTORY__ command "build" { Command.pipeline { Stages.restore "MyLibrary.slnx" Stages.build projects } } } ``` The stages register the inputs they need. Check `build --help` for the resulting flags. ## Reuse inputs - `Baked.Dotnet.config`: build configuration. - `Baked.NuGet.apiKey`: NuGet key, with an environment default. - `Baked.Common.isCI`: CI detection and override. - `Baked.SemVer.bump`: semantic version increment or explicit version. Bind them with the same `input` block used for your own options. ## Choose a versioning workflow The agnostic SemVer bump rewrites project `Version` and sets `AssemblyVersion` to the major version. It does not infer releases from commits. For conventional commits and changelogs, use the [ShipIt bump](../extensions/shipit.md#use-the-shipit-backed-bump). It computes the release version and updates configured package versions together. Use one bump workflow per release. Full signatures are in [Capabilities](CAPABILITIES.md#baked) and the [workflow reference](workflow-reference.md#baked-the-batteries). --- # Agents and JSON > Inspect a command and consume structured execution results. Use the build's own command model to discover what it accepts and what it will run. ## Inspect before execution ```shell dotnet run --project Build.fsproj -- build --help dotnet run --project Build.fsproj -- build --schema --json dotnet run --project Build.fsproj -- build --explain --json ``` `--help` describes registered inputs. `--schema --json` returns the command schema. `--explain --json` returns a static execution plan without running steps or effectful conditions. Text `--explain` may evaluate conditions such as a Git branch check. ## Consume a run result ```shell dotnet run --project Build.fsproj -- build --json ``` A JSON run ends with one result document containing the outcome, exit code, reports, failures, and timings. Child processes may still print output before that document. Detected AI environments default `--json` to true. Detection happens per invocation. `PARTAS_BUILD_DISABLE_AI=true` disables that automatic default; explicit `--json` still works. Use `--json false` to request text for an individual invocation. ## Handle exit codes - `0`: success, including help and inspection. - `1`: execution failed. - `2`: invalid command or input; execution did not start. - `130`: cancelled. In an F# host, [invoke a reusable root](hosting.md) to receive a `RunResult` directly. ## Read these docs as Markdown The site publishes [llms.txt](/Partas.Build/llms.txt), [llms-full.txt](/Partas.Build/llms-full.txt), and Markdown copies beside guide pages. Each HTML guide advertises its Markdown URL with an alternate link. Copy the [agent instructions](https://shayanhabibi.github.io/Partas.Build/AGENTS-snippet.md) into a consumer repository to document how its build should be inspected. --- # Troubleshooting > Fix common input, argument, and composition mistakes. ## A path with spaces becomes several arguments Use `run (cmd $"dotnet build {path}")`. Direct string interpolation into `run` loses the argument boundary. ## A second `let!` does not compile Bind inputs in one `let! … and! …` group. Derive dependent values after that group. The parser needs to know every input before reading their values. ## A custom operation under `if` or `match` does not compile Choose a value first, then pass it to the operation: ```fsharp open Partas.Build let hasKey = false let push = if hasKey then "dotnet nuget push package.nupkg" else "dotnet pack" let publish = stage "publish" { run push } ``` A conditional that yields an entire stage is supported. ## `yield!` and custom operations conflict Yield a list from the computation expression instead. See [Composition](composition.md). ## An option is missing from help The selected command registers inputs its stages bind. Yield the input-bearing stage or pipeline into that command. Constructing an input elsewhere does not register it. ## A stage is skipped unexpectedly Inspect it with `--explain`. Conditions conjoin; a second `whenBranch` narrows the stage to both branches. Use `whenAny` to express alternatives. ## Help or explain installs a tool Move setup into an execution step. Input readers and module initialization should not perform workflow side effects. The [ShipIt extension](../extensions/shipit.md) supplies explicit setup commands. --- # Workflow reference > Detailed examples of stages, inputs, execution settings, and Baked. Detailed examples complement the [task-focused guides](index.md). ## Layers | Layer | CE | Produces | |---|---|---| | Step | `run`, `echo`, … | one action inside a stage | | Stage | `stage "name" { }` | `StageContext` | | Inputs | `input { }` | `InputSpec<'T>` | | Pipeline | `pipeline "name" { }` | `PipelineContext` or `InputSpec` | | Command | `command "name" { }` | `System.CommandLine.Command` | | Root | `rootCommand argv { }` | `int` exit code — **it runs immediately** | | Hosted root | `Command.root { }` | `RootCommandDefinition` — runs on each `Command.invoke args` | ## A first pipeline ```fsharp open Partas.Build let hello = pipeline "hello" { workingDir __SOURCE_DIRECTORY__ stage "greet" { echo "building" run "dotnet --version" } } ``` `step` -> `stage` -> `stage` -> ... -> `pipeline` -> `command` -> `rootCommand`. Nothing runs until the root command does. ## Steps A step is anything yielded inside a `stage`. `run` is heavily overloaded; three overloads matter: ```fsharp let steps = stage "steps" { // a whole command line, split on whitespace honouring quotes run "dotnet build --no-restore" // executable and arguments kept apart run "dotnet" "build --no-restore" // an F# function; also Async<_>, Task<_>, StageContext -> _ and Result-returning variants run (fun (ctx: StageContext) -> printfn "%s" ctx.Name) } ``` ### Interpolation: use `cmd` `run $"..."` binds the **`string`** overload, which flattens the holes and re-splits the result on whitespace. A path containing a space becomes two arguments. Route interpolation through `cmd` instead: it keeps each hole as exactly one argument and lets the platform do the escaping: ```fsharp // v------- will break if directly passed verbatim let project = "src/My Project/My Project.fsproj" let interpolated = stage "build" { run (cmd $"dotnet build {project}") // one argument, space and all } ``` `runSensitive` takes a `FormattableString` directly, needs no `cmd`, and masks every hole as `***` in the log while passing the real value to the process: ```fsharp let password = "drowssap" let login = stage "login" { runSensitive $"docker login -u me -p {password}" } ``` It masks *every* hole. Build a `Cmd` by hand when only one argument is secret. `Secrets` is a set of argument indices: ```fsharp let pushArgs = [ "nuget"; "push"; "bin/x.nupkg"; "--api-key"; password ] let push = stage "push" { run { Cmd.ofList "dotnet" pushArgs with Secrets = Set.singleton 4 } } ``` *New in >0.2.2*: wrap sensitive strings with `Cmd.secret` or `Cmd.sensitive`. Every command runner picks them up and quotes any string containing spaces. ```fsharp let pushSecret = stage "push" { run $"dotnet nuget push bin/x.nupkg --api-key {Cmd.secret password}" } ``` ## Conditions `when'` and its friends set whether a stage runs. They **conjoin**. A second condition narrows the first rather than replacing it: ```fsharp let conditional = stage "release only" { whenBranch "master" whenNot { envVar "CI" } // master AND not CI run "dotnet pack" } ``` `whenAll`, `whenAny` and `whenNot` are CEs that combine leaf conditions (`branch`, `branches`, `envVar`, `platformWindows`, `platformLinux`, `platformOSX`, and a literal `when'`). An empty `whenAll { }` is active, the identity of `forall`. An empty `whenAny { }` is not. ```fsharp let combined = stage "publish" { whenAny { branch "master" envVar "FORCE_PUBLISH" } run "dotnet nuget push" } ``` `when'` also accepts a whole `StageContext`. It runs for real, side effects and console output included, and its success is the answer. A `bool` carries no reason, so `--explain` prints a stage it turns off as `(skipped)`. Name the reason as a second argument, `when' (not quick) "--quick is set"`, and `--explain` prints `(skipped: --quick is set)`. ## Inputs A stage that needs a CLI flag binds it in an `input` CE. It is then lifted into any command that asks for it, with no further wiring: ```fsharp module Options = let quick = Input.option "--quick" |> Input.alias "-q" |> Input.description "Skip restores and cleaning" let config = Input.option "--configuration" |> Input.alias "-c" |> Input.def "Release" |> Input.acceptOnlyFromAmong [ "Debug"; "Release" ] let restore = input { let! quick = Options.quick return stage "restore" { when' (not quick) run "dotnet restore" } } ``` Bind several sources with `and!`, never with nested `let!`: ```fsharp let build = input { let! quick = Options.quick and! config = Options.config return stage "build" { when' (not quick) run (cmd $"dotnet build -c {config}") } } ``` The CE rejects a second `let!`; bind every source in one `let! … and! …` block. Binding this way is what lifts flags into the command line help without first evaluating pipelines. ### Harvesting upward A pipeline or stage that asks for an input, or nests an `input` request, is wrapped in an `InputSpec<'T>`, which tracks inputs and unions them by reference. `--quick` and `--configuration` appear under `build --help` without being named anywhere but the stages that read them: ```fsharp let buildCommand = command "build" { description "Restores and builds the solution" pipeline "build" { workingDir __SOURCE_DIRECTORY__ restore build } } ``` Flags sit on the commands whose stages read them, not on the root. `addInput` covers the remainder: flags no pipeline asks for that a root command still wants to expose. ## Wiring the root `rootCommand` parses and invokes immediately, returning the process exit code. It belongs in `main`: ```fsharp let main argv = rootCommand argv { description "My build" addCommands [ buildCommand ] } ``` A command with no pipelines is a grouping node: it gets no action, so System.CommandLine reports the missing subcommand and prints help instead of succeeding silently. The exit code tells a mistaken invocation from a broken build: `0` success (help, `--version` and `--explain` included), `1` a stage failed, `2` the command line did not parse or validate — an unknown option, a missing subcommand, a producer arrangement dependency validation rejects — and `130` the run was cancelled. The `ExitCode` module names the four. ### Invoking from a host `rootCommand` reads its arguments once and runs at construction. A long-lived host — an F# interactive session, a test — builds the root once with `Command.root`, which takes every `rootCommand` operation and runs nothing, then invokes it as often as it likes. `Command.invoke` answers a `RunResult`: the exit code, its `RunOutcome`, and each pipeline run's `ScopeReport`s and `StageTiming`s. ```fsharp let root = Command.root { description "My build" addCommands [ buildCommand ] } let buildQuick () = let result = root |> Command.invoke [ "build"; "--quick"; "true" ] printfn "%d failure(s)" result.Failures.Length result.ExitCode ``` `root.Invoke(args, output = writer, cancellationToken = token)` sends help, `--explain`, the timing summary and parse errors to `writer` instead of the console, and cancels the running pipeline — its processes with it — when `token` fires. ## Composition ### Stages nest A stage can be yielded inside another stage, arbitrarily deep, with no separate grouping concept: ```fsharp let nested = stage "outer" { run "dotnet --version" stage "inner" { whenWindows run "dotnet --info" } } ``` ### Settings inherit Settings resolve outward: stage, then parent stage, then pipeline. A pipeline-level `workingDir` or `envVars` defaults every stage that does not override it: ```fsharp let inherited = pipeline "inherited" { workingDir __SOURCE_DIRECTORY__ envVars [ ("CI", "true") ] timeoutForStage 300 stage "uses the pipeline's dir and env" { run "dotnet --info" } stage "overrides just the dir" { workingDir __SOURCE_DIRECTORY__ run "dotnet --version" } } ``` ### Stages are values A stage is an ordinary value, so reuse is ordinary F#. Return them from functions, put them in lists, iterate over them: ```fsharp let testProject (name: string) = stage $"test {name}" { run (cmd $"dotnet test {name}") } let testAll = pipeline "test" { for proj in [ "A.fsproj"; "B.fsproj" ] do testProject proj } ``` The same works one layer up: a `pipeline` is a value, and a `command` can run several in declaration order. [Composing reusable blocks](composition.md) covers nesting, lists of blocks, and stages that carry their own inputs. ### Nameless Pipelines A short CLI command often runs a single pipeline named the same as the command. `Command.pipeline` covers this: it inherits its description and name from the command it is defined within, essentially `pipeline null { }`. ```fsharp let namelessPipe = command "build" { description "Build projects" Command.pipeline { stage "build" { run "dotnet build" } } } ``` ### Command-level defaults A `command` also takes the pipeline settings: `workingDir`, `envVars`, `timeout`, `timeoutForStage`, `timeoutForStep`, `acceptExitCodes`, `outputTo`, `silentOutput`, `captureOutput`, `redirectOutput`, `noPrefixForStep`, `noStdRedirectForStep`, `runBeforeEachStage`, `runAfterEachStage`, `post`, `verbosity`, `verbose` and `quiet`. Set on the command, these are **defaults for every pipeline the command runs**, saving repetition across pipelines: ```fsharp let ciCommand = command "ci" { workingDir __SOURCE_DIRECTORY__ envVars [ ("CI", "true") ] quiet pipeline "build" { stage "build" { run "dotnet build" } } pipeline "test" { verbosity Verbosity.Verbose // this pipeline says its own piece; see below stage "test" { run "dotnet test" } } } ``` A default never overwrites a pipeline's own setting: the pipeline wins regardless of write order. A default written *below* the pipelines still applies, because defaults are folded in once the whole command is built, not as each pipeline is yielded. A setting counts as the pipeline's own once it differs from a freshly created pipeline's initial value: - `workingDir`, the timeouts, `outputTo` and friends, `verbosity`: hand over as soon as the pipeline names them. - `post`: hands over as soon as the pipeline declares a post stage. - `acceptExitCodes`: hands over as soon as it widens the set beyond `0`. - the hooks: hand over as soon as one is installed. - `envVars`: merges per variable — keys the pipeline set stay, the rest arrive from the command. - `noPrefixForStep`, `noStdRedirectForStep`: plain booleans with no "unset" state, so setting one to its existing value is indistinguishable from not setting it. Stages are never touched: a command default fills in pipeline settings only. ## Advanced ### Parallelism `parallel'` makes a stage run its steps concurrently. It takes a flag, a throttle, or a function returning either: `StageContext -> bool`, `-> int voption`, `-> Choice`, `-> Choice`. Every overload resolves to the same `int voption`: | You write | Resolves to | Behaviour | |---|---|---| | nothing | `ValueNone` | sequential | | `parallel' false` | `ValueNone` | sequential | | `parallel' 1` | `ValueSome 1` | sequential | | `parallel' n` (`n > 1`) | `ValueSome n` | at most `n` steps in flight | | `parallel'`, `parallel' true` | `ValueSome -1` | unbounded | | `parallel' 0`, `parallel' -1` | `ValueSome n`, `n < 1` | unbounded | ```fsharp let fanOut = stage "fan out" { parallel' 2 run "dotnet build A.fsproj" run "dotnet build B.fsproj" run "dotnet build C.fsproj" } ``` The bound is exact: a stage set to `2` never has a third step in flight. To choose a mode at runtime, return the choice from a single condition rather than writing two operations: ```fsharp let adaptive = stage "fan out" { parallel' (fun (_: StageContext) -> if System.Environment.ProcessorCount > 4 then ValueSome 4 else ValueNone) run "dotnet build A.fsproj" run "dotnet build B.fsproj" } ``` ### Settings overwrite, conditions conjoin The two halves of the stage CE compose differently. Mixing them up is the most common surprise. `parallel'`, `workingDir`, `timeout` and the rest are **settings**: the last one written wins and an earlier one leaves no trace. ```fsharp let lastWins = stage "settings" { parallel' 4 parallel' false // sequential; the 4 is gone, not combined with run "dotnet build" } ``` `when'`, `whenBranch`, `whenWindows` and the rest are **conditions**: each narrows the stage to the logical AND of everything declared so far, so a second condition can only make the stage run less often. ```fsharp let narrows = stage "conditions" { whenBranch "master" whenWindows // master AND Windows, not Windows instead of master run "dotnet pack" } ``` To widen a condition, write **one** `whenAny { }` containing both alternatives: a second operation would narrow instead. To switch parallel modes, write **one** condition function returning the mode: a second operation would discard the first. ### Timeouts and cancellation A stage takes `timeout` and `timeoutForStep`; a pipeline takes those plus `timeoutForStage`. A stage takes plain `int` seconds, `float` seconds or a `TimeSpan`; a pipeline takes `int`, `float` seconds or a `TimeSpan`. A timeout cancels the stage and kills the whole process tree it started, grandchildren included. ### Post stages `post` stages run after the main stages whether or not the pipeline succeeded: the place for teardown. ```fsharp let withTeardown = pipeline "integration" { stage "up" { run "docker compose up -d" } stage "test" { run "dotnet test" } post [ stage "down" { run "docker compose down" } ] } ``` ### Failure control - `continueStepsOnFailure` keeps a stage going after a failed step. - `continueStageOnFailure` keeps the pipeline going after a failed stage. - `continueOnStepFailure` sets both. - `acceptExitCodes` widens what counts as success (the default is `0`). - `failIfIgnored` turns a skipped stage into a failure. ### Hooks `runBeforeEachStage` and `runAfterEachStage` take a `StageContext -> unit` and fire around every stage in the pipeline. ### Where step output goes By default a step's output goes straight to the console. `outputTo` redirects it, inherited by sub-stages like any other setting: | | | |---|---| | `silentOutput` | dropped | | `captureOutput` | held, and lifted into the error message if a step fails | | `redirectOutput (fun stream line -> …)` | handed over line by line, as it arrives | | `outputTo sink` | any of the above as a `StageOutput` value, for when the choice is made at run time | The common case is a test run: silent when it passes, and its own output as the reason when it does not. ```fsharp let quietTests = pipeline "test" { stage "test" { captureOutput run "dotnet test" } } ``` `captureOutput` lifts stderr into the step's error, or everything written if there was none. A failing stage still says why, on the console and in the GitHub Actions annotation. Pass an `OutputCapture` to keep the lines regardless of outcome: ```fsharp let log = OutputCapture.create() let audited = pipeline "audit" { stage "scan" { captureOutput log run "dotnet list package --vulnerable" } post [ stage "report" { run (fun _ -> File.WriteAllText ("scan.log", OutputCapture.text log)) } ] } ``` - `OutputCapture.lines`: both streams, in the order they arrived. - `OutputCapture.errors`: stderr only. - `OutputCapture.text` / `OutputCapture.errorText`: the same, joined. - `OutputCapture.failureText`: what a failure lifts. It does not cover three things: - The pipeline's own log (stage rules, command lines, timings) is `verbosity`, not `outputTo`. A stage wanting both quiet needs `quiet` *and* `silentOutput`. - A bare `printfn` inside a step is not routable. Use `echo`, or `StageContext.writeLine ctx StdStream.Out`. - `noStdRedirectForStep` overrides all of it: without redirection there is no stream to route. ## Baked: the batteries `Partas.Build.Baked` is the layer of options every build CLI ends up writing anyway, ready made, documented, and shipped as its own package. Each declaration is a `BuildOption<'T>`: `.option` the flag, `.argument` the positional equivalent. | | | |---|---| | `Baked.Dotnet.config` | `configuration`/`-c`, over `release`/`r`/`debug`/`d` case-insensitively, as `string option` | | `Baked.NuGet.apiKey` | `nuget-key`/`--nuget`/`-k`, help name `APIKEY`, defaulting to `$NUGET_API_KEY` | | `Baked.SemVer.bump` | `bump`, parsed to a `Bump` DU over `major\|minor\|patch\|alpha\|beta\|rc\|preview\|`, defaulting to `patch` | | `Baked.Common.isCI` | `--ci`, defaulting to true when the environment looks like CI | `BuildOption.map`, `.mapOpt` and `.mapArg` apply an `Input.*` combinator to both forms or to one. Both forms are `ActionInput` values, so they bind in an `input` CE exactly as a hand-rolled option does: ```fsharp let packaging = input { let! config = Baked.Dotnet.config.option and! key = Baked.NuGet.apiKey.option let config = Option.defaultValue "Release" config return stage "pack" { when' key.IsSome run (cmd $"dotnet pack -c {config}") } } ``` ### Versioning a project file `Baked.SemVer.Version` is semantic-version arithmetic over the `Bump` DU. `Baked.SemVer.Version.IO` applies it to a project file. The `bump` stage is ready made: it binds `--ci`, skips itself when set, and takes the projects to edit as an `InputSpec`, so the CLI decides what `--project` accepts. ```fsharp let bumpAsArgument = Baked.SemVer.Stages.bumpArgument Options.projects // minor -p MyLib let bumpAsOption = Baked.SemVer.Stages.bumpOption Options.projects // --bump minor -p MyLib ``` `Baked.SemVer.Version.IO.writeVersion` rewrites `` and `` in the first `PropertyGroup`, adding either if absent, and returns the previous ``. It skips the `` declaration and byte-order mark `XDocument.Save` would otherwise add, so a bump reads as a one-line diff. `` is the package version and moves however it is bumped. `` only takes the major. Moving it on a patch bump breaks anything not rebuilt in the same pass, with `Could not load file or assembly ', Version=…'`. Paired with `Baked.Common.isCI`, versions are bumped locally and committed, so CI packs whatever the project file carries rather than inventing one at build time. The arithmetic itself: | from | bump | to | |---|---|---| | `1.2.3` | `patch` | `1.2.4` | | `1.2.3` | `minor` | `1.3.0` | | `1.2.3` | `major` | `2.0.0` | | `1.2.3` | `alpha` | `1.2.4-alpha.1` | | `1.2.4-alpha.1` | `alpha` | `1.2.4-alpha.2` | | `1.2.4-alpha.2` | `rc` | `1.2.4-rc.1` | | `1.2.4-rc.1` | `patch` | `1.2.4` | | anything | `Target "2.0.0-nightly.7"` | `2.0.0-nightly.7` | A `patch` on a pre-release *releases* it rather than moving past it. `major`/`minor` drop the tag outright. `Target` is taken verbatim, unparsed. ## Antipatterns **Interpolating straight into `run`.** `run $"dotnet build {path}"` picks the `string` overload and re-splits on whitespace. Use `run (cmd $"...")`. **Nested `let!` in `inputs`.** It does not compile, by design. Use `and!`. A source that depends on another's value needs a single input with a richer type, or a runtime check inside a step. **Custom operations under `if` or `match`.** F# forbids it. Build the value first, then apply the operation unconditionally: ```fsharp // won't compile stage "publish" { if hasKey then run pushToNuget else run pushToLocal } // do this let push = if hasKey then pushToNuget else pushToLocal stage "publish" { run push } ``` A whole *stage* under an `if` is fine: an untaken branch contributes nothing. ```fsharp pipeline "ci" { stage "build" { run "dotnet build" } if not skipTests then stage "test" { run "dotnet test" } } ``` **Mixing `yield!` with a custom operation in the same CE.** F# rejects it (`FS3086`). Yield a list instead: `pipeline "p" { [ yield! blocks; yield extra ] }`, and put the settings on a stage inside. **Registering options on the root so every command has them.** `build --help` lists `--configuration` *because* a build stage binds it. Hand-registering reintroduces the drift the library exists to remove. **Expecting a second condition to replace the first.** Conditions conjoin. Settings do not. A second `whenBranch` narrows to both branches at once (so: never), where a second `parallel'` silently throws the first away. To widen a condition, put the alternatives in one `whenAny { }`. **`runSensitive` with a bound interpolation.** `runSensitive` takes a `FormattableString`, and F# only converts a `string` when a single overload is in play. It has no `InputSpec` form: bind the value outside the stage and use `runSensitive $"…"` inside it as normal. ## API reference The [API reference](https://shayanhabibi.github.io/Partas.Build/reference/) is generated from the XML documentation on each custom operation. --- # Composing reusable blocks [The guide](index.md) introduces one stage at a time. This page builds a library of reusable *blocks* — stages that carry their own CLI inputs — and assembles them into pipelines and commands. The file listings under *Composition across files* are separate scripts. Snippets marked with compiler diagnostics show unsupported syntax. ## The shape of a block A block is a function returning a stage. It returns a plain `StageContext` when it needs no CLI flag, or an `InputSpec` from an `input { }` CE when it does. Both are ordinary values and both are yieldable anywhere a stage is. ```fsharp open Partas.Build module Options = let config = Input.option "--configuration" |> Input.alias "-c" |> Input.def "Release" |> Input.acceptOnlyFromAmong [ "Debug"; "Release" ] let quick = Input.option "--quick" |> Input.alias "-q" |> Input.description "Skip restores and cleaning" let verbose = Input.option "--verbose" |> Input.alias "-v" module Blocks = /// No inputs: a plain `StageContext`. let clean (project: string) = stage $"clean {project}" { run (cmd $"dotnet clean {project}") } /// Reads `--quick`: an `InputSpec`. let restore (project: string) = input { let! quick = Options.quick return stage $"restore {project}" { when' (not quick) run (cmd $"dotnet restore {project}") } } /// Reads two options. Bind them in one `let! … and!` block. let build (project: string) = input { let! config = Options.config and! verbose = Options.verbose let level = if verbose then "detailed" else "minimal" return stage $"build {project}" { run (cmd $"dotnet build {project} -c {config} -v {level}") } } ``` ## Yielding blocks A pipeline takes blocks in declaration order and unions the options they declare, whether a block is a bare stage or a spec: ```fsharp let one = pipeline "one project" { workingDir __SOURCE_DIRECTORY__ Blocks.clean "MyLib.fsproj" Blocks.restore "MyLib.fsproj" Blocks.build "MyLib.fsproj" } ``` `one` is an `InputSpec` declaring `--quick`, `--configuration` and `--verbose` exactly once, contributed by `build` and `restore`. ## Loops and lists A `for` loop and a yielded list both work; they differ only in where the collection comes from: ```fsharp let projects = [ "MyLib.fsproj"; "MyLib.Tool.fsproj"; "MyLib.Tests.fsproj" ] let looped = pipeline "all projects" { for project in projects do Blocks.build project } let listed = pipeline "core" { [ Blocks.restore "MyLib.fsproj" Blocks.build "MyLib.fsproj" ] } ``` Both forms union the inputs of every element: `looped` still declares `--configuration` and `--verbose` once across three stages. Reach for the list form when a custom operation would otherwise force `yield!`, which F# refuses to mix with custom operations (`FS3086`): ```fsharp // FS3086 pipeline "p" { yield! blocks; timeout 60.0 } // this compiles pipeline "p" { [ yield! blocks ] } ``` ## Nesting A stage nested inside another stage is one step of its parent, so blocks group with no separate concept, and an input declared at any depth surfaces on the command running the pipeline. Here the innermost stage, three levels down, is the only thing that names `--configuration`: ```fsharp let deep = command "ci" { description "Restore, build and test" pipeline "ci" { workingDir __SOURCE_DIRECTORY__ timeoutForStage 600 stage "prepare" { Blocks.clean "MyLib.fsproj" Blocks.restore "MyLib.fsproj" } stage "compile" { stage "libraries" { parallel' 2 for project in [ "MyLib.fsproj"; "MyLib.Tool.fsproj" ] do Blocks.build project } stage "tests" { Blocks.build "MyLib.Tests.fsproj" } } } } ``` `ci --help` lists `--quick`, `--configuration` and `--verbose`; the stages that read them registered them. A setting placed *after* a nested block still applies to the enclosing stage, so ordering is free: ```fsharp let settingsAfter = stage "compile" { Blocks.build "MyLib.fsproj" timeout 300 whenNot { envVar "SKIP_BUILD" } } ``` ## Blocks that take blocks A block is a value, so a block factory can take other blocks as arguments — the usual way to build a house style: a wrapper adding retries, timing, teardown or a condition to whatever it receives. ```fsharp /// Wraps stages in a named group with a shared timeout, and a teardown that always runs. let group name (seconds: int) (stages: StageContext seq) = stage name { timeout seconds [ yield! stages yield stage $"{name} done" { echo $"finished {name}" } ] } let grouped = pipeline "grouped" { group "prepare" 60 [ Blocks.clean "MyLib.fsproj" ] } ``` When the wrapped stages carry inputs, the wrapper takes an `InputSpec` list and returns an `InputSpec`, joined through the `input` CE: ```fsharp let inputGroup name (seconds: int) (blocks: InputSpec list) = input { let! stages = InputSpec.sequence blocks return stage name { timeout seconds stages } } let inputGrouped = pipeline "release" { inputGroup "compile" 600 [ Blocks.restore "MyLib.fsproj"; Blocks.build "MyLib.fsproj" ] } ``` `InputSpec.sequence` turns a list of specs into one spec of a list, unioning the inputs. `InputSpec.traverse fn items` does the same over a mapping. These are the two functions to reach for when writing this kind of wrapper. ## Adding an input of the wrapper's own A wrapper can bind flags the wrapped blocks know nothing about, alongside the sequenced blocks: ```fsharp let skipTests = Input.option "--skip-tests" |> Input.description "Build the tests but do not run them" let testGroup (blocks: InputSpec list) = input { let! stages = InputSpec.sequence blocks and! skip = skipTests return stage "test" { when' (not skip) stages run "dotnet test --no-build" } } let tested = command "test" { description "Build and test" pipeline "test" { testGroup [ Blocks.restore "MyLib.fsproj"; Blocks.build "MyLib.fsproj" ] } } ``` `test --help` now lists `--skip-tests` next to the three the blocks declared. ## What does not compose: a block returning a block's spec One shape the CE cannot express is worth recognising on sight. A block that binds inputs returns an `InputSpec`. A *second* `input { }` that builds one of those inside its own `return` produces an `InputSpec>`, which nothing downstream accepts: ```fsharp // Does not work. `bumpBlock` is itself an `input { }`, so `return` wraps a spec inside a spec. let bumpFromArgument project = input { let! config = Options.config return bumpBlock (InputSpec.ofInput Sources.bumpArgument) project } ``` There is no `InputSpec.flatten`, and no sound `flatten` can exist. Flattening would read the inner spec's `Inputs`, which exist only once its `Read` runs, and `Read` needs the `ParseResult` those inputs configure — the circularity `InputSpec` exists to break, and why `input` has no `Bind`: a sequential `let!` fails with `FS0708` instead of compiling into an option set that cannot be registered. **Binding is a layer boundary**: one layer binds, the layers above harvest. When two blocks share a body but differ in where a value comes from, pass the *source* in as an `InputSpec` instead of passing a read value out as one. The shared body keeps one `let!`/`and!` group; callers vary only the spec they hand over: ```fsharp module Sources = /// The bump kind as a positional argument — `bump minor`. let bumpArgument = Input.argument "bump" |> Input.def "patch" /// The same value as an option — `release --bump minor`. let bumpOption = Input.optionMaybe "--bump" /// The shared body. `bumpSource` arrives as a spec, so it joins the one bind group like any other source. let bumpBlock (bumpSource: InputSpec) (project: string) = input { let! bump = bumpSource and! config = Options.config return stage $"bump {project}" { run (cmd $"dotnet build {project} -c {config} /p:Bump={bump}") } } let bumpFromArgument project = bumpBlock (InputSpec.ofInput Sources.bumpArgument) project let bumpFromOption project = let source = InputSpec.ofInput Sources.bumpOption |> InputSpec.map (Option.defaultValue "patch") bumpBlock source project ``` `InputSpec.ofInput` lifts a bare `ActionInput` into a spec; `InputSpec.map` adapts its value, so a source can be defaulted or reshaped before being handed over. Both commands below declare `--configuration`; one declares the argument, the other declares `--bump`: ```fsharp let bumping = [ command "bump" { bumpFromArgument "MyLib.fsproj" } command "release" { bumpFromOption "MyLib.fsproj" } ] ``` The same rule covers a helper needing no source of its own: give it the already-read values as plain arguments and let the caller bind. An `input { }` nested inside a `return` is always the error. ## Commands over stages A command needs no explicit `pipeline`. Stages yielded straight into it become one implicit pipeline that takes the command's name and description: ```fsharp let flat = command "build" { description "Build the solution" Blocks.restore "MyLib.sln" Blocks.build "MyLib.sln" } ``` Consecutive stages share that one pipeline, its settings, its run and its `whenStage` cross-references. A command can also mix implicit and explicit pipelines; declaration order is preserved. `addInput` covers the remainder: a flag the command should expose that no stage binds. A command also takes the pipeline settings themselves — `workingDir`, `envVars`, the timeouts, the output operations, the hooks, `post`, `verbosity` — and hands them to every pipeline it runs, including the implicit one. They are defaults: a pipeline that sets the same thing keeps its own value. See [command-level defaults](workflow-reference.md#command-level-defaults). ## Conditional assembly An `if` with no `else` is fine around a whole stage or block; the untaken branch contributes nothing. Custom operations are the exception, since F# forbids those under an `if`. ```fsharp let includeDocs = System.Environment.GetEnvironmentVariable "DOCS" = "1" let conditional = pipeline "release" { Blocks.build "MyLib.fsproj" if includeDocs then stage "docs" { run "dotnet run --project docs/docs.fsproj -- build" } } ``` Bind and branch inside the `input` CE for a condition known only after parsing; it is ordinary F# with no such restriction: ```fsharp let maybeClean = input { let! quick = Options.quick return if quick then stage "skip clean" { echo "skipping clean" } else Blocks.clean "MyLib.fsproj" } ``` ## Putting it together A small, complete build assembled entirely from blocks: ```fsharp let mainCommand argv = rootCommand argv { description "MyLib build" addCommands [ command "build" { description "Restore and build" Command.pipeline { workingDir __SOURCE_DIRECTORY__ for project in projects do Blocks.build project } } tested command "release" { description "Pack and push" pipeline "release" { inputGroup "compile" 600 [ Blocks.build "MyLib.fsproj" ] stage "pack" { whenBranch "master" captureOutput run "dotnet pack MyLib.fsproj --no-build" } post [ stage "notify" { echo "released" } ] } } ] } ``` Each command lists only the flags its own stages read: `build` gets `--configuration` and `--verbose`, `test` adds `--skip-tests` and `--quick`, `release` gets what its blocks declare. ## Composition across files A `Command` is an ordinary value and `Yield` takes one. A script that owns a slice of the build exposes its commands as a binding; another script `#load`s the file and yields the binding. Two rules make it work: 1. **The command tree is a value.** Bind it with `let`; do not `exit` it at the point of definition. 2. **The `rootCommand` invocation is gated.** `#load` executes the loaded script top to bottom, so an ungated `exit (rootCommand … )` takes over the loading script's process. `Args.scriptName ()` returns the filename the process launched with — the loaded script's own name only when it is the one running. ```fsharp // tools/generate-wire.fsx #load "../prelude.fsx" open Partas.Build module Options = let target = Input.option "--target" |> Input.mapFromAmong [ "node", "node"; "browser", "browser" ] |> Input.def "node" |> Input.description "Runtime the wire layer is generated for" module Stages = let generate layer = input { let! target = Options.target return stage $"generate {layer}" { echo $"{layer} -> {target}" } } let generateCommands = command "generate" { description "Regenerate a wire layer" command "ast" { Stages.generate "ast" } command "proto" { Stages.generate "proto" } } if Args.scriptName () = ValueSome "generate-wire.fsx" then exit (rootCommandOfScript { generateCommands }) ``` ```fsharp // build.fsx #load "tools/generate-wire.fsx" open Partas.Build exit ( rootCommandOfScript { description "The repository build" ``Generate-wire``.generateCommands command "test" { stage "test" { echo "testing" } } }) ``` `dotnet fsi build.fsx -- generate ast --help` lists `--target` with its two legal values; `dotnet fsi tools/generate-wire.fsx -- generate ast --help` prints the same thing, from the same declaration. ### The module name `#load` gives a file F# derives the module name from the filename, capitalising the first letter and wrapping the whole in double backticks if a character is illegal in an identifier. `tools/generate-wire.fsx` becomes `` `Generate-wire` ``, not `GenerateWire` or `Generate_wire`. An `open` of the wrong guess fails to compile, naming a module that does not exist. ### Names must be unique among siblings `System.CommandLine` builds a lookup keyed by command name. Yielding the loaded `generate` command into another command also called `generate` throws `ArgumentException: An item with the same key has already been added. Key: generate`. Yield it at a level where its name is free, or wrap it in a differently-named parent: ```fsharp command "wire" { description "Everything wire-related" ``Generate-wire``.generateCommands } ``` ### What this replaces Without it, four scripts collapse into one with a `--only ` flag whose legal values live only in its description string: the four layer names spelled once in the flag and once in a dispatching `match`, nothing checking the two agree, and four `fsi` startups each resolving NuGet for a run touching all four. Composition gives four `command` bindings instead — `--help` lists them because they exist, and one process resolves packages once. ## Reference - [Guide](index.md) — steps, conditions, inputs, output, timeouts, `Baked`. - [API reference](https://shayanhabibi.github.io/Partas.Build/reference/) — every custom operation, from its XML documentation. --- # EasyBuild.ShipIt `Partas.Build.EasyBuild.ShipIt` wraps [EasyBuild.ShipIt](https://github.com/easybuild-org/EasyBuild.ShipIt) as reusable operations, inputs, stages and commands. ShipIt calculates release versions from conventional commits, updates changelogs and configured project files, and can open release pull requests. The extension supports `net10.0`, `net8.0` and `netstandard2.0`. The default ShipIt tool version, 3.1.0, requires .NET 10. Add the extension as a project/package reference using the [installation guide](../Build/installation.md). ## Add the commands to your build ```fsharp open Partas.Build open Partas.Build.EasyBuild.ShipIt let root = Command.root { workingDir __SOURCE_DIRECTORY__ addCommand Commands.shipit addCommand (command "bump" { Command.pipeline { Stages.bump } }) } let build args = Command.invoke args root ``` `Commands.shipit` exposes `generate`, `github`, `bump`, `version`, `conventions`, `setup`, and `init changelog|workflows|github|project`. Each command registers the inputs its stages read. Help, schema and explain inspect the workflow without running setup or release operations. For a build project, invoke it as `dotnet run --project Build.fsproj -- shipit ...`. For a script, use `dotnet fsi build.fsx -- shipit ...` after your usual root invocation. The following examples use the build-project form. ## Set up the local tool ```shell dotnet run --project Build.fsproj -- shipit setup ``` Setup creates the Git repository's `.config/dotnet-tools.json` if absent, installs EasyBuild.ShipIt 3.1.0 only if unregistered, and restores the tools. Existing registrations retain their pinned version. `--tool-version` selects the version for a new registration. There is no global installation. From a subdirectory, setup still uses the closest Git repository's manifest. Competing nested manifests or an alternate root `dotnet-tools.json` are rejected; invoke from the repository root or remove the competing manifest. Normal ShipIt prefab stages apply the same boundary check. Setup is explicit: bumping does not install the tool or change GitHub settings. ## Keep package versions and changelogs together ```shell dotnet run --project Build.fsproj -- shipit init project \ --changelog src/MyLibrary/CHANGELOG.md \ --project src/MyLibrary/MyLibrary.fsproj ``` Use a single line in PowerShell. This creates the changelog if missing and registers a ShipIt XML updater. Project/changelog input paths are relative to the stage's working directory; updater paths are relative to the changelog: ```yaml updaters: - xml: file: MyLibrary.fsproj selector: /Project/PropertyGroup/Version ``` Configuration preserves existing front matter, comments, changelog body, line endings and UTF-8 BOM. Repeated setup does not duplicate equivalent XML updaters. Projects must contain exactly one unconditional literal `` under ``. Conditional, computed or externally defined versions require manual updater configuration. Existing `updaters` must use a nonempty block sequence; remove an empty key before initialization. For independently versioned packages, use a changelog for each package. For a shared version, register multiple projects against one changelog using `--project`. When adopting an existing package, seed its changelog with its last released version and set `last_commit_released` to the correct commit. ShipIt calculates from the changelog's release history, rather than reading the `.fsproj` version as its baseline. `AssemblyVersion` remains under your compatibility policy. This setup only registers updates for the package `Version`. ## Use the ShipIt-backed bump ```shell dotnet run --project Build.fsproj -- shipit bump --allow-branch master --dry-run dotnet run --project Build.fsproj -- shipit bump --allow-branch master ``` `Stages.bump`, `Stages.bumpWith` and `shipit bump` always use **local mode**: ShipIt calculates the version and updates the changelog and configured project files without pushing or creating a PR. Even consumer-supplied options with another mode are forced to local. The bump command has no `--mode` input. Do not follow this with the agnostic `Partas.Build.Baked.SemVer` bump. ShipIt owns the release version; a second independent increment could make the changelog and package disagree. Review the local changes, commit them, then compose your normal build, pack and publish stages. Consumer-defined inputs can supply the configuration directly: ```fsharp let bump = Stages.bumpWith (InputSpec.ret { ReleaseOptions.defaults with AllowedBranches = [ "master" ] PreRelease = Some "beta" }) let bumpCommand = command "bump" { Command.pipeline { bump } } ``` Omit `PreRelease` for a stable CLI request, or supply a prefix such as `beta`. A prerelease configured in changelog front matter still applies when the CLI prefix is omitted. ## Release and initialize GitHub explicitly `shipit generate` supports `--mode local|pull-request|push`, defaulting to `pull-request`. `shipit github` selects the GitHub provider explicitly and accepts a sensitive `--token`; it falls back to `GITHUB_TOKEN` or upstream `gh` authentication. Token values are masked in command labels, schema and explain; upstream tool output remains upstream's responsibility. Release inputs also include `--allow-branch` (default `main`, multiple values), `--pre-release PREFIX`, remote hostname/owner/repository overrides, `--skip-invalid-commit`, `--skip-merge-commit`, and `--dry-run`. Use `--allow-branch master` for repositories using master. `shipit init changelog` and `shipit init workflows` delegate upstream scaffolding, which refuses to overwrite existing files. `shipit init github` applies GitHub merge and workflow-permission settings using authenticated `gh`; preview them with `--dry-run`. `--org` additionally changes organization settings and requires the appropriate administrative access. CI needs full Git history, conventional commits and suitable GitHub permissions/authentication. ShipIt generates release changes; it does not publish NuGet packages. See [upstream's configuration and CI recipes](https://github.com/easybuild-org/EasyBuild.ShipIt#configuration) for changelog include/exclude rules and release automation. ## Choose the composition level - `Operations`: pure `Cmd` builders for upstream operations. Low-level callers supply their own working directory/tool resolution. - `Inputs`: reusable typed CLI inputs, including token defaults evaluated per invocation. - `Stages`: ready-made stages and `*With` variants for consumer-supplied `InputSpec` values. They inherit normal execution settings. - `Commands.shipit`: the complete command tree for a build root. The extension is also included in the [API reference](https://shayanhabibi.github.io/Partas.Build/reference/). --- # External Annotations — F# surface [The overview](index.md) covers what external annotations are and why a sidecar is the only reliable way to ship them. This page is the F# API, for a build CLI that would rather own the behaviour than shell out to `partas-annotations`. Everything here comes from `Partas.Build.ExternalAnnotations`, the library the tool itself is built from. Adopting one operation does not oblige you to take the others. ## Two entry points per operation Each operation exists twice: | Operation | Pipeline knows the paths | Paths come from the command line | |---|---|---| | generate | `generateTo assembly output` | `generateStage` / `generateCommand` | | verify | `verifyPackage nupkg`, `verifyPackageOf min nupkg` | `verifyStage` / `verifyCommand` | | init | `initIn directory` | `initStage` / `initCommand` | Use the left column when your pipeline already knows the paths, the right when the user supplies them. Both forms sit over one implementation. ## In a pipeline Generation belongs after the build and before the pack. `generateTo` still binds `--strict` itself: the pipeline is an `InputSpec`, and the flag appears under the command's `--help` without being registered anywhere: ```fsharp open Partas.Build open Partas.Build.ExternalAnnotations let packPipeline = pipeline "pack" { stage "build" { run "dotnet build -c Release" } generateTo "src/My.Lib/bin/Release/net8.0/My.Lib.dll" "obj/My.Lib.ExternalAnnotations.xml" stage "pack" { run "dotnet pack -c Release --no-build" } verifyPackageOf 596 "bin/My.Lib.1.0.0.nupkg" } ``` `verifyPackage` is the same check with a floor of `0`: every assembly under `lib/` must have a sidecar beside it, but an empty sidecar passes. `verifyPackageOf` adds the floor and catches the characteristic failure: a green build, a sidecar generated from the wrong assembly, and a package that annotates nothing. Verification opens the `.nupkg` a consumer would download rather than any proxy for it, and fails the stage (non-zero exit) naming each sidecar that is `(missing)` or short. ## As commands The three commands are values. Hand them to your own root command and you have the tool, without installing it: ```fsharp let main argv = rootCommand argv { description "My build" addCommands [ generateCommand; verifyCommand; initCommand ] } ``` That is, modulo the description, the entire `partas-annotations` executable. To expose one operation under your own name and defaults, wrap the option-driven stage instead: ```fsharp let annotationsCommand = command "annotations" { description "Generates the external annotations sidecar" Command.pipeline { generateStage } } ``` > `Partas.Build.ExternalAnnotations` is `[]` and exposes its own `Options` module (`strict`, > `assembly`, `output`, `package`, `minMembers`, `directory`, `annotationsTool`, `force`). If your build CLI has > a module by that name, open the namespace before defining yours, or qualify. ## Writing the targets file `initIn` is the pipeline form of `init`. It writes `Directory.Build.targets` into the given directory and refuses to overwrite an existing one without `--force`: ```fsharp let initPipeline = pipeline "init" { initIn "src/My.Lib" } ``` Note the directory: MSBuild takes the first `Directory.Build.targets` it finds walking up, so writing it beside the packable project scopes annotations to that project and leaves test and sample projects untouched. `writeTargets` is the same thing without a stage around it: a path and an optional command to pin inside the file: ```fsharp let writeItSomewhereElse () = writeTargets "build/Partas.ExternalAnnotations.targets" (Some "dotnet partas-annotations") ``` Pinning the command means the committed artifact says what runs, instead of depending on an ambient MSBuild property set correctly on every machine. ## Packing a project you cannot commit into `packArgs` builds the `-p:CustomAfterMicrosoftCommonTargets=` argument that injects the same MSBuild logic into a single `dotnet pack`, with no file added to the project. The path must be absolute; `packArgs` makes it so: ```fsharp let packWithInjection = stage "pack" { run (Cmd.ofList "dotnet" ([ "pack"; "-c"; "Release" ] @ packArgs "build/Partas.ExternalAnnotations.targets")) } ``` This is the exception, not the rule. A committed `Directory.Build.targets` is what makes *every* pack correct, including the ones that never go through your pipeline. ## The generator directly `Partas.ExternalAnnotations` has no dependency on Partas.Build, so it can be used from anywhere — an MSBuild task, a script, a test: ```fsharp open Partas.ExternalAnnotations let result = generate "bin/Release/net8.0/My.Lib.dll" "obj/My.Lib.ExternalAnnotations.xml" printfn $"%d{result.Members} members, %d{result.Sites} sites, %d{result.Types} types" ``` `GenerateResult` carries `Types`, `Sites`, `Members` and `Skipped`. `Skipped` pairs a type or member with the reason nothing could be emitted for it. A non-empty list means those annotations are **absent from the output**, exactly what `--strict` turns into a failure. `generate` collects **every** attribute in the `JetBrains.Annotations` namespace — `NotNull`, `Pure`, `ContractAnnotation`, `StringFormatMethod`, `LanguageInjection`, `PublicAPI` and the rest — on types, members, parameters, returns and generic parameters, with nothing to declare on the calling side. `generateWith` narrows that, and takes extra probe directories for an assembly whose references do not resolve from its own folder plus its `deps.json` closure: ```fsharp generateWith (AttributeFilter.Named [ "NotNullAttribute" ]) [ "packages/JetBrains.Annotations/lib/netstandard2.0" ] assembly output ``` `AttributeFilter` is `JetBrains` (the default, the whole namespace), `Named` (simple names, whatever namespace declares them) or `Where` (a `namespace -> name -> bool` predicate). Both are ordinary functions; `ExternalAnnotationGenerator` sits under them for the counts, the `XDocument`, or `PrintfMembers()` for a human-readable dump of every site found. It is `IDisposable`: it holds a `MetadataLoadContext` and an open `PEReader` over the assembly. ## Recipes Concrete end-to-end setups are on the [recipes page](external-annotations-recipes.md). --- # External Annotations — Recipes Concrete setups. Background is on the [overview](index.md); the F# API is [here](external-annotations-api.md). ## Ship annotations from a repo you own The default. One-time setup, then every pack is correct. ```shell dotnet new tool-manifest # if you have none dotnet tool install Partas.ExternalAnnotations.Tool dotnet partas-annotations init --annotations-tool "dotnet partas-annotations" git add .config/dotnet-tools.json Directory.Build.targets ``` CI needs `dotnet tool restore` before `dotnet pack`; nothing else changes. ## Scope annotations to one project A repo with a packable library, a test project and three samples wants the targets file next to the library, not at the root. MSBuild takes the first `Directory.Build.targets` it finds walking up, so this leaves everything else untouched: ```shell dotnet partas-annotations init --directory src/My.Lib --annotations-tool "dotnet partas-annotations" ``` The emitted file chain-imports any parent `Directory.Build.targets`, so an existing root one keeps working. ## No tool available (or no tool dependency wanted) Commit the sidecar instead. The targets file packs it when generation is not configured, and the path it looks for is fixed: ```shell # generate once, by hand, into the fallback location dotnet partas-annotations generate \ --assembly src/My.Lib/bin/Release/net8.0/My.Lib.dll \ --output src/My.Lib/ExternalAnnotations/My.Lib.ExternalAnnotations.xml dotnet partas-annotations init --directory src/My.Lib # no --annotations-tool git add src/My.Lib/ExternalAnnotations src/My.Lib/Directory.Build.targets ``` Packs are correct today, with no tool and no warnings. Output is BOM-free and stably sorted, so regenerating it produces a reviewable diff, not whole-file churn. Switch to generation later by re-running `init` with `--annotations-tool ... --force`. A committed file is only as fresh as the last time you ran that command. Regenerate it in the same change that adds or moves annotated members. ## Multi-targeted packages Nothing to do. Generation runs once per inner build, from that TFM's own assembly, into a per-TFM path under `obj\`. Confirm it, because the failure mode is silent and plausible-looking: ```shell unzip -l bin/Release/My.Lib.1.0.0.nupkg | grep ExternalAnnotations # lib/net8.0/My.Lib.ExternalAnnotations.xml # lib/net10.0/My.Lib.ExternalAnnotations.xml ``` If both files are byte-identical *and* your TFMs expose different surfaces, something is pointing every inner build at one path. Check `PartasExternalAnnotationsFile`, which overrides the per-TFM default. ## Pack a project you cannot commit into Inject the targets file for one invocation: ```shell dotnet pack -c Release \ -p:CustomAfterMicrosoftCommonTargets=$(pwd)/build/Partas.ExternalAnnotations.targets \ -p:PartasExternalAnnotationsTool="dotnet partas-annotations" ``` The path must be absolute; MSBuild otherwise resolves it per project. From a pipeline, use `packArgs`, which absolutises it for you. Write the file out first with `writeTargets` if it is not already in your repo. ## Gate CI on the annotation count The check worth having is not "the file exists" but "the file annotates what it used to". `verifyPackageOf` takes the floor: ```fsharp let publish = pipeline "publish" { stage "pack" { run "dotnet pack -c Release" } verifyPackageOf 596 "bin/Release/My.Lib.1.0.0.nupkg" stage "push" { run "dotnet nuget push bin/Release/My.Lib.1.0.0.nupkg" } } ``` Or from the shell, once the package is built: ```shell dotnet partas-annotations verify --package bin/Release/My.Lib.1.0.0.nupkg --min-members 596 ``` Exit code `1` and a message naming each sidecar that is `(missing)` or short. Raise the floor when the number goes up; a bare `--min-members 1` still catches the wrong-assembly case, which is the one that otherwise ships. ## Fail on skipped members A skipped member is an annotation that is silently absent from the output. In your own library's pipeline, where the count is known, promote it: ```shell dotnet partas-annotations generate --assembly ... --output ... --strict ``` ### MSBuild Limitation Under MSBuild: ```xml dotnet partas-annotations ``` `--strict` cannot be appended there: the targets build the whole `generate` command line. For a strict pack, generate in the pipeline with `generateTo` (which binds `--strict`) and point `PartasExternalAnnotationsFile` at its output. ## Narrowing the attribute set Everything in the `JetBrains.Annotations` namespace is collected by default. To ship only some of it, name the attributes — either on the command line: ```shell dotnet partas-annotations generate --assembly My.Lib.dll --output My.Lib.ExternalAnnotations.xml --attribute NotNullAttribute --attribute LanguageInjectionAttribute ``` or in code, where `AttributeFilter.Where` also takes an arbitrary `namespace -> name -> bool` predicate: ```fsharp open Partas.ExternalAnnotations let r = generateWith (AttributeFilter.Named [ "NotNullAttribute" ]) [] "bin/Release/net8.0/My.Lib.dll" "obj/My.Lib.ExternalAnnotations.xml" ``` A pipeline stage can fix the set instead of leaving it to `--attribute`, with `generateOnlyTo`. Both constructor arguments and named arguments (`Prefix`, `Suffix`, …) are carried through, including for attribute types that reflection cannot materialise — those are decoded from the raw metadata blob. ## Adopt the commands without the tool Your build CLI already exists; give it the three commands rather than a tool dependency: ```fsharp open Partas.Build open Partas.Build.ExternalAnnotations [] let main argv = rootCommand argv { description "My build" addCommands [ Commands.build; Commands.test; generateCommand; verifyCommand; initCommand ] } ``` > The generator itself, `Partas.ExternalAnnotations.generate` and `generateWith`, has no dependency on > Partas.Build, so a CLI built on something else calls it directly. The verify check does have one; such a > CLI shells out to `partas-annotations verify` instead. Then pin *that* as the generator, and pack-time generation goes through your CLI: ```shell dotnet run --project Build.fsproj -- init --annotations-tool "dotnet run --project Build.fsproj --" ``` Slower per pack than a tool — it builds the CLI first — but there is nothing extra to restore or publish. ## Inspect what would be emitted Before committing to a number, dump every site the generator found: ```fsharp open Partas.ExternalAnnotations use gen = new ExternalAnnotationGenerator ("bin/Release/net8.0/My.Lib.dll") printfn $"%d{gen.MemberCount} members / %d{gen.SiteCount} sites / %d{gen.TypeScanCount} types" gen.PrintfMembers () ``` `PrintfMembers` prints each member's XML doc id with its sites and decoded attribute arguments: the fastest way to tell "the attribute is not where I thought it was" from "the sidecar is not reaching Rider". ## Troubleshooting **Nothing injects in Rider.** In order: is the sidecar in the package (`unzip -l`)? Is it beside the assembly in `lib//`, not in a folder of its own? Does it contain the member (`PrintfMembers` vs. the XML)? Is the annotation at *member* level — parameter-level on a mangled F# extension member does not inject. Rider caches external annotations per assembly version; bump the package version rather than repacking the same one. **`MSB4011 ... will be ignored`.** Both injection routes are active and one of them is re-importing. Harmless, but it means either the targets file is imported twice by path, or a `Directory.Build.targets` copy was placed somewhere that makes its parent chain-import loop back. **The package ships annotations that do not match the source.** Generation failed and the fallback shipped. The targets only use a generated file on exit code `0`, so check the `Exec` output in the pack log — it runs at normal importance, so `-v:n` shows it. **"External annotations were not packed for <tfm>".** Neither generation nor a committed file produced anything. The warning names the path it looked for; put a file there or configure `PartasExternalAnnotationsTool`. **`NETSDK1054` when packing the tool.** `PackAsTool` cannot target `netstandard2.0`. The tool is `net8.0` with `RollForward=LatestMajor`; only the generator library multi-targets down. --- # Partas.Build.EasyBuild.ShipIt | Namespace | | |---|---| | [`Partas.Build.EasyBuild.ShipIt`](/Partas.Build/reference/partas-build-easybuild-shipit/partas-build-easybuild-shipit/) | 6 declarations | --- # Partas.Build.EasyBuild.ShipIt ## Modules | | | Assembly | |---|---|---| | [`Inputs`](/Partas.Build/reference/partas-build-easybuild-shipit/partas-build-easybuild-shipit/inputs/) | Reusable inputs registered only by the stages that read them | `Partas.Build.EasyBuild.ShipIt` | | [`Operations`](/Partas.Build/reference/partas-build-easybuild-shipit/partas-build-easybuild-shipit/operations/) | Pure commands for the EasyBuild.ShipIt local tool | `Partas.Build.EasyBuild.ShipIt` | | [`ProjectSetup`](/Partas.Build/reference/partas-build-easybuild-shipit/partas-build-easybuild-shipit/projectsetup/) | Explicitly register project-version updaters in existing ShipIt changelogs | `Partas.Build.EasyBuild.ShipIt` | | [`Stages`](/Partas.Build/reference/partas-build-easybuild-shipit/partas-build-easybuild-shipit/stages/) | Composable ShipIt stages | `Partas.Build.EasyBuild.ShipIt` | ## Record | | | Assembly | |---|---|---| | [`ReleaseOptions`](/Partas.Build/reference/partas-build-easybuild-shipit/partas-build-easybuild-shipit/releaseoptions/) | Upstream release options; changelog front matter owns file updaters | `Partas.Build.EasyBuild.ShipIt` | ## Union | | | Assembly | |---|---|---| | [`ReleaseMode`](/Partas.Build/reference/partas-build-easybuild-shipit/partas-build-easybuild-shipit/releasemode/) | Where ShipIt applies a calculated release | `Partas.Build.EasyBuild.ShipIt` | --- # Inputs
module Inputs
| | | |---|---| | Assembly | `Partas.Build.EasyBuild.ShipIt` | | Attributes | `[]` | Reusable inputs registered only by the stages that read them. ## Functions and values
allowedBranches: ActionInput<string list>
bump: InputSpec<ReleaseOptions>
Local-only options; no --mode is registered.
changelogPath: ActionInput<string option>
dryRun: ActionInput<bool>
organization: ActionInput<bool>
preRelease: ActionInput<string option>
projects: ActionInput<string list>
remoteHostname: ActionInput<string option>
remoteOwner: ActionInput<string option>
remoteRepository: ActionInput<string option>
skipInvalidCommit: ActionInput<bool>
skipMergeCommit: ActionInput<bool>
token: InputSpec<string option>
toolVersion: InputSpec<string>
--- # Operations
module Operations
| | | |---|---| | Assembly | `Partas.Build.EasyBuild.ShipIt` | | Attributes | `[]` | Pure commands for the EasyBuild.ShipIt local tool. Constructing commands has no side effects. ## Functions and values
conventions () : Cmd
Show supported conventional commit types.
generate (options: ReleaseOptions) : Cmd
Generate changelogs and apply configured file updaters.
github (options: ReleaseOptions) (token: string option) : Cmd
Explicit GitHub provider, with the supplied token masked in command labels.
initChangelog (path: string option) : Cmd
Create a changelog. Upstream refuses to overwrite an existing file.
initGithub (organization: bool) (dryRun: bool) : Cmd
Apply or preview GitHub settings, optionally including organization settings.
initWorkflows () : Cmd
Scaffold upstream GitHub workflow templates, without overwriting existing files.
version () : Cmd
Report the installed tool version.
--- # ProjectSetup
module ProjectSetup
| | | |---|---| | Assembly | `Partas.Build.EasyBuild.ShipIt` | | Attributes | `[]` | Explicitly register project-version updaters in existing ShipIt changelogs. ## Functions and values
configure (changelog: string) (projects: string list) : unit
Add missing XML updaters atomically, retaining the existing changelog bytes outside inserted YAML.
--- # ReleaseMode
[<Struct>]
type ReleaseMode =
    | Local
    | PullRequest
    | Push
| | | |---|---| | Assembly | `Partas.Build.EasyBuild.ShipIt` | | Attributes | `[]` | Where ShipIt applies a calculated release. ## Cases
Local
PullRequest
Push
## Properties
member IsLocal: bool
member IsPullRequest: bool
member IsPush: bool
--- # ReleaseOptions
type ReleaseOptions =
    {
        AllowedBranches: string list
        Mode: ReleaseMode
        PreRelease: string option
        RemoteHostname: string option
        RemoteOwner: string option
        RemoteRepository: string option
        SkipInvalidCommit: bool
        SkipMergeCommit: bool
        DryRun: bool
    }
| | | |---|---| | Assembly | `Partas.Build.EasyBuild.ShipIt` | Upstream release options; changelog front matter owns file updaters. ## Fields
AllowedBranches: string list
Mode: ReleaseMode
PreRelease: string option
RemoteHostname: string option
RemoteOwner: string option
RemoteRepository: string option
SkipInvalidCommit: bool
SkipMergeCommit: bool
DryRun: bool
## Functions and values
defaults: ReleaseOptions
Upstream defaults, with no prerelease requested.
--- # Stages
module Stages
| | | |---|---| | Assembly | `Partas.Build.EasyBuild.ShipIt` | | Attributes | `[]` | Composable ShipIt stages. Setup effects occur only at execution time. ## Functions and values
bump: InputSpec<StageContext>
bumpWith (options: InputSpec<ReleaseOptions>) : InputSpec<StageContext>
Calculate and apply versions locally, even if the consumer supplies another mode.
configureProjects (changelog: string) (projects: string list) : InputSpec<StageContext>
configureProjectsWith (changelog: InputSpec<string>) (projects: InputSpec<string list>) : InputSpec<StageContext>
Explicit project adoption. Creates a changelog only when absent, then registers XML updaters.
conventions: InputSpec<StageContext>
generate: InputSpec<StageContext>
generateWith (options: InputSpec<ReleaseOptions>) : InputSpec<StageContext>
github: InputSpec<StageContext>
githubWith (options: InputSpec<ReleaseOptions>) (token: InputSpec<string option>) : InputSpec<StageContext>
initChangelog: InputSpec<StageContext>
initChangelogWith (path: InputSpec<string option>) : InputSpec<StageContext>
initGithub: InputSpec<StageContext>
initGithubWith (organization: InputSpec<bool>) (dryRun: InputSpec<bool>) : InputSpec<StageContext>
initWorkflows: InputSpec<StageContext>
setup: InputSpec<StageContext>
setupWith (version: InputSpec<string>) : InputSpec<StageContext>
version: InputSpec<StageContext>
--- # Partas.Build | Namespace | | |---|---| | [`Partas.Build`](/Partas.Build/reference/partas-build/partas-build/) | 48 declarations | --- # Partas.Build ## Modules | | | Assembly | |---|---|---| | [`AiEnvironment`](/Partas.Build/reference/partas-build/partas-build/aienvironment/) | Cooperative agent detection for output defaults, following is-ai-agent's environment rules | `Partas.Build` | | [`Annotations`](/Partas.Build/reference/partas-build/partas-build/annotations/) | Structured diagnostics emitted by build scripts and by the runner | `Partas.Build` | | [`BuildStage`](/Partas.Build/reference/partas-build/partas-build/buildstage/) | | `Partas.Build` | | [`ConditionsBuilder`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/) | | `Partas.Build` | | [`ConsumerTypes`](/Partas.Build/reference/partas-build/partas-build/consumertypes/) | The model types a consumer names in a signature, reachable from `Partas.Build` | `Partas.Build` | | [`DependenciesBuilder`](/Partas.Build/reference/partas-build/partas-build/dependenciesbuilder/) | | `Partas.Build` | | [`ErrorHandling`](/Partas.Build/reference/partas-build/partas-build/errorhandling/) | | `Partas.Build` | | [`ExitCode`](/Partas.Build/reference/partas-build/partas-build/exitcode/) | The process exit codes a command invocation ends with | `Partas.Build` | | [`InputSpec`](/Partas.Build/reference/partas-build/partas-build/inputspec/) | | `Partas.Build` | | [`InputsBuilder`](/Partas.Build/reference/partas-build/partas-build/inputsbuilder/) | | `Partas.Build` | | [`Operations`](/Partas.Build/reference/partas-build/partas-build/operations/) | Commands as operations: deferred, stage-configured, and explicit about their failure policy | `Partas.Build` | | [`OutputHandling`](/Partas.Build/reference/partas-build/partas-build/outputhandling/) | | `Partas.Build` | | [`PipelineBuilder`](/Partas.Build/reference/partas-build/partas-build/pipelinebuilder/) | | `Partas.Build` | | [`Producer`](/Partas.Build/reference/partas-build/partas-build/producer/) | | `Partas.Build` | | [`Stage`](/Partas.Build/reference/partas-build/partas-build/stage/) | Stages defined from what they consume | `Partas.Build` | | [`StageBuilder`](/Partas.Build/reference/partas-build/partas-build/stagebuilder/) | | `Partas.Build` | | [`StageContext`](/Partas.Build/reference/partas-build/partas-build/stagecontext/) | | `Partas.Build` | | [`Summary`](/Partas.Build/reference/partas-build/partas-build/summary/) | What a finished run spent, stage by stage | `Partas.Build` | ## Records | | | Assembly | |---|---|---| | [`DependencyPlan`](/Partas.Build/reference/partas-build/partas-build/dependencyplan/) | The static producer graph for all pipelines a command will invoke | `Partas.Build` | | [`DependencySpec`](/Partas.Build/reference/partas-build/partas-build/dependencyspec/) | The prerequisites of one piece of work, and the typed value their results read as | `Partas.Build` | | [`EnvArg`](/Partas.Build/reference/partas-build/partas-build/envarg/) | | `Partas.Build` | | [`ExecutionState`](/Partas.Build/reference/partas-build/partas-build/executionstate/) | What one pipeline invocation has published, and the boundary at which a scope discards it | `Partas.Build` | | [`ExplainedCondition`](/Partas.Build/reference/partas-build/partas-build/explainedcondition/) | One condition of a stage, as `--explain` answered it | `Partas.Build` | | [`ExplainedPipeline`](/Partas.Build/reference/partas-build/partas-build/explainedpipeline/) | A pipeline as `--explain` describes it | `Partas.Build` | | [`ExplainedStage`](/Partas.Build/reference/partas-build/partas-build/explainedstage/) | A stage as `--explain` describes it | `Partas.Build` | | [`FailureContext`](/Partas.Build/reference/partas-build/partas-build/failurecontext/) | What one failed execution of a scope hands the handlers registered on it | `Partas.Build` | | [`Operation`](/Partas.Build/reference/partas-build/partas-build/operation/) | Work deferred until a stage executes it | `Partas.Build` | | [`PipelineRun`](/Partas.Build/reference/partas-build/partas-build/pipelinerun/) | A pipeline run by an invocation, as its scopes reported themselves | `Partas.Build` | | [`ProducerLocation`](/Partas.Build/reference/partas-build/partas-build/producerlocation/) | A declaration-order address of a stage in one pipeline | `Partas.Build` | | [`ProducerPlacement`](/Partas.Build/reference/partas-build/partas-build/producerplacement/) | A producer's validated position in one invocation | `Partas.Build` | | [`RunResult`](/Partas.Build/reference/partas-build/partas-build/runresult/) | The structured result of one command invocation | `Partas.Build` | | [`ScopeAddress`](/Partas.Build/reference/partas-build/partas-build/scopeaddress/) | Where a scope sits in the run | `Partas.Build` | | [`ScopeReport`](/Partas.Build/reference/partas-build/partas-build/scopereport/) | What one execution of a scope did, the evidence its steps left, and whether its failure reaches the scope containing it | `Partas.Build` | | [`ScopeReports`](/Partas.Build/reference/partas-build/partas-build/scopereports/) | The scopes one pipeline invocation ran, as they reported themselves | `Partas.Build` | | [`StageAddress`](/Partas.Build/reference/partas-build/partas-build/stageaddress/) | Where one stage sits in a pipeline | `Partas.Build` | | [`StageTiming`](/Partas.Build/reference/partas-build/partas-build/stagetiming/) | The wall time of one stage of a run, with how the stage ended | `Partas.Build` | | [`StageTimings`](/Partas.Build/reference/partas-build/partas-build/stagetimings/) | The stages a pipeline run has finished, as the tree the stages nest into | `Partas.Build` | | [`StepFailure`](/Partas.Build/reference/partas-build/partas-build/stepfailure/) | A failure one step produced, with the step it came from | `Partas.Build` | ## Unions | | | Assembly | |---|---|---| | [`ActionInputSource`](/Partas.Build/reference/partas-build/partas-build/actioninputsource/) | | `Partas.Build` | | [`ExplainMode`](/Partas.Build/reference/partas-build/partas-build/explainmode/) | How `--explain` answers a condition that performs IO or runs work to answer | `Partas.Build` | | [`ExplainedConditionState`](/Partas.Build/reference/partas-build/partas-build/explainedconditionstate/) | The answer `--explain` records for one condition of a stage | `Partas.Build` | | [`ExplainedStatus`](/Partas.Build/reference/partas-build/partas-build/explainedstatus/) | Whether a stage would run, as its conditions answered | `Partas.Build` | | [`ExplainedStep`](/Partas.Build/reference/partas-build/partas-build/explainedstep/) | A step as `--explain` describes it; `index` counts from zero, as a run result's `step` and `path` do | `Partas.Build` | | [`ProducerId`](/Partas.Build/reference/partas-build/partas-build/producerid/) | A key allocated for one producer declaration and retained by every copy of its handle | `Partas.Build` | | [`ProducerValues`](/Partas.Build/reference/partas-build/partas-build/producervalues/) | Values published during one invocation or attempt scope | `Partas.Build` | | [`RunOutcome`](/Partas.Build/reference/partas-build/partas-build/runoutcome/) | The category of an invocation's result; `ExitCode` carries the corresponding code | `Partas.Build` | ## Class | | | Assembly | |---|---|---| | [`ActionInput`](/Partas.Build/reference/partas-build/partas-build/actioninput/) | | `Partas.Build` | ## Type abbreviation | | | Assembly | |---|---|---| | [`FailureHandler`](/Partas.Build/reference/partas-build/partas-build/failurehandler/) | Runs when the scope it is registered on fails | `Partas.Build` | --- # ActionInput
[<Class>]
type ActionInput =
    new (source: ActionInputSource) : ActionInput
    member Source: ActionInputSource
| | | |---|---| | Assembly | `Partas.Build` | ## Constructors
new (source: ActionInputSource) : ActionInput
## Properties
member Source: ActionInputSource
--- # ActionInputSource
type ActionInputSource =
    | ParsedOption of _
    | ParsedArgument of _
    | Context
    | Injection of obj
| | | |---|---| | Assembly | `Partas.Build` | ## Cases
ParsedOption of _
ParsedArgument of _
Context
Injection of obj
## Properties
member IsContext: bool
member IsInjection: bool
member IsParsedArgument: bool
member IsParsedOption: bool
--- # AiEnvironment
module AiEnvironment
| | | |---|---| | Assembly | `Partas.Build` | Cooperative agent detection for output defaults, following is-ai-agent's environment rules. Inherited markers identify a harness; they do not prove that an AI initiated a command. ## Functions and values
[<Literal>] DisableVariable: string = "PARTAS_BUILD_DISABLE_AI"
Set to a nonblank value other than 0, false, no or off to disable automatic agent defaults.
detect () : Lazy<bool>
A fresh lazy detection of the process environment, evaluated only when its Value is requested. Use a fresh result for each invocation in a long-lived host.
detectWith (readEnvironment: string -> string) : Lazy<bool>
A lazy detection using the supplied environment reader. Nothing is read until Value is requested. The result is cached within this Lazy; create a new one to observe a changed environment. Only environment signals are used: the README's /opt/.devin filesystem probe is excluded.
--- # Annotations
module Annotations
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Structured diagnostics emitted by build scripts and by the runner. ## Declared inside | | | |---|---| | [`Annotation`](/Partas.Build/reference/partas-build/partas-build/annotations/annotation/) | A diagnostic with an optional title and repository-relative source location | | [`AnnotationLevel`](/Partas.Build/reference/partas-build/partas-build/annotations/annotationlevel/) | | --- # Annotation
[<Struct>]
type Annotation =
    {
        Level: AnnotationLevel
        Message: string
        Title: string voption
        File: string voption
        Line: int voption
        EndLine: int voption
        Column: int voption
        EndColumn: int voption
    }
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | A diagnostic with an optional title and repository-relative source location. Line and column numbers are one-based. Emitting an error does not itself fail the stage. ## Fields
Message: string
Title: string voption
File: string voption
Line: int voption
EndLine: int voption
Column: int voption
EndColumn: int voption
## Functions and values
error (message: string) : Annotation
An error diagnostic. Reporting it leaves execution failure policy to the caller.
notice (message: string) : Annotation
An informational diagnostic, with no source location or title until supplied by the caller.
warning (message: string) : Annotation
A warning diagnostic, with no source location or title until supplied by the caller.
--- # AnnotationLevel
[<Struct>]
type AnnotationLevel =
    | Notice
    | Warning
    | Error
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` `[]` | ## Cases
Notice
Warning
Error
## Properties
member IsError: bool
member IsNotice: bool
member IsWarning: bool
--- # BuildStage
module BuildStage
| | | |---|---| | Assembly | `Partas.Build` | ## Functions and values
addEnvVars (kvs: (string * string) seq) (build: StageContext -> StageContext) : BuildStage
merge (firstStage: BuildStage) (secondStage: BuildStage) : StageContext -> StageContext
mergeMany (stages: BuildStage seq) : BuildStage
mergeManyWith (mergeFn: BuildStage -> BuildStage -> StageContext -> StageContext) (stages: BuildStage seq) : BuildStage
setAcceptableExitCodes (codes: int seq) (build: StageContext -> StageContext) : BuildStage
setContinueStepsOnFailure (continueStepsOnFailure: bool) (build: StageContext -> StageContext) : BuildStage
setFailIfIgnored (failIfIgnored: bool) (build: StageContext -> StageContext) : BuildStage
setFailIfNoActiveSubStage (failIfNoActiveSubStage: bool) (build: StageContext -> StageContext) : BuildStage
## Extension members
member inline when' (build: BuildStage, value: bool) : BuildStage
Sets whether the stage is active using a literal boolean condition. The value is typically a boolean bound by an enclosing `input` CE.
member inline when' (build: BuildStage, value: bool, skipReason: string) : BuildStage
Sets whether the stage is active from a literal boolean condition; `skipReason` is reported by `--explain` against an inactive stage. `when' (not quick) "--quick is set"` renders a skipped stage as `(skipped: --quick is set)`. | Parameter | | |---|---| | `build` | | | `value` | The condition. | | `skipReason` | The reason reported by `--explain` when `value` is false. |
member inline when' (build: BuildStage, stage: StageContext) : BuildStage
Runs a stage as a condition and uses its success as the activation answer. The condition stage runs immediately during condition evaluation and must complete (or fail) before proceeding.
member inline whenBranch (build: BuildStage, branch: string) : BuildStage
Adds a Git branch condition for a single branch name.
member inline whenBranches (build: BuildStage, branches: string seq) : BuildStage
Adds a Git branch condition that matches any of the specified branch names.
member inline whenEnvVar (build: BuildStage, arg: EnvArg) : BuildStage
Adds an environment variable condition using an `EnvArg` specification.
member inline whenEnvVar (build: BuildStage, name: string) : BuildStage
Adds an environment variable condition by name only. The condition is met if the environment variable is set (non-empty).
member inline whenEnvVar (build: BuildStage, name: string, value: string) : BuildStage
Adds an environment variable condition that checks both name and value. The condition is met only if the environment variable is set and its value matches exactly.
member inline whenLinux (build: BuildStage, ?isTrue: bool) : BuildStage
Adds a condition that is met when running on Linux.
member inline whenOSX (build: BuildStage, ?isTrue: bool) : BuildStage
Adds a condition that is met when running on macOS.
member inline whenPlatform (build: BuildStage, platform: OSPlatform) : BuildStage
member inline whenWindows (build: BuildStage, ?isTrue: bool) : BuildStage
Adds a condition that is met when running on Windows.
--- # ConditionsBuilder
module ConditionsBuilder
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | ## Declared inside | | | |---|---| | [`Conditions`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/conditions/) | The leaf conditions, as plain `StageContext -> bool` functions | | [`ConditionsBuilder`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/conditionsbuilder/) | Collects conditions for `whenAll`, `whenAny` and `whenNot` | | [`EffectfulCondition`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/effectfulcondition/) | A condition that performs IO or runs work to answer, made by `Conditions.effectful` | | [`ValueConditions`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/valueconditions/) | Stage-producing conditions that bind the value they tested | | [`WhenAllBuilder`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/whenallbuilder/) | | | [`WhenAnyBuilder`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/whenanybuilder/) | | | [`WhenEnvBuilder`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/whenenvbuilder/) | Describes one environment variable, in place of a wall of `whenEnvVar` overloads | | [`WhenNotBuilder`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/whennotbuilder/) | | | [`WhenStageBuilder`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/whenstagebuilder/) | A stage run purely for its result | ## Functions and values
whenAll: WhenAllBuilder
The stage is active when every condition in the body is met. An empty body is always active.
whenAny: WhenAnyBuilder
The stage is active when any of the conditions in the body is met. An empty body is never active.
whenEnv: WhenEnvBuilder
The stage is active when the described environment variable is set, and matches one of its values if any are given.
whenNot: WhenNotBuilder
The stage is active when none of the conditions in the body is met. An empty body is always active.
whenStage (name: string) : WhenStageBuilder
The stage is active when the stage described in the body finishes successfully.
--- # Conditions
module Conditions
| | | |---|---| | Assembly | `Partas.Build` | The leaf conditions, as plain `StageContext -> bool` functions. Fun.Build's originals branch on `Mode` to double as help and verification printers. That mode is not ported — System.CommandLine generates the help — so each of these is only the predicate Fun.Build evaluates under `Mode.Execution`. ## Declared inside | | | |---|---| | [`Reason`](/Partas.Build/reference/partas-build/partas-build/conditionsbuilder/conditions/reason/) | The text `--explain` prints against a stage each of the conditions above turned off | ## Functions and values
combined (name: string) (conditions: BuildStageIsActive list) (condition: BuildStageIsActive) : BuildStageIsActive
`condition`, marked `effectful` when any of `conditions` is, described as `name` over the marked conditions' descriptions.
effectful (description: string) (condition: BuildStageIsActive) : BuildStageIsActive
Marks `condition` as performing IO or running work to answer, described by `description`. A static `--explain` reports a marked condition as unevaluated, under its description, and leaves it uncalled. The mark belongs to the returned function value: a lambda calling it is unmarked unless marked itself. `whenAll`, `whenAny` and `whenNot` mark their result when any condition in them is marked.
tryEffect (condition: BuildStageIsActive) : string voption
The description of `condition` when it is marked `effectful`.
whenBranch (branch: string) : BuildStageIsActive
whenBranches<'a when 'a :> string seq> (branches: 'a) : BuildStageIsActive
The branch is read with git branch --show-current in the stage's working directory.
whenEnvArg (info: EnvArg) (ctx: StageContext) : bool
whenEnvVar (name: string) : BuildStageIsActive
whenEnvVarValue (name: string) (value: string) : BuildStageIsActive
whenPlatform (platform: OSPlatform) (StageContext) : bool
whenStageSucceeds (stage: StageContext) : BuildStageIsActive
Runs `stage` as a condition stage, reparented onto the stage being tested, and reports whether it succeeded. The stage runs for real: side effects and console output included. The condition reads `continues`, the policy-folded outcome — a condition stage carrying `continueStageOnFailure` reports itself as succeeded even where it failed.
--- # Reason
module Reason
| | | |---|---| | Assembly | `Partas.Build` | The text `--explain` prints against a stage each of the conditions above turned off. Public because the custom operations that attach them are `inline`, which requires everything they reference to be reachable from the call site. ## Functions and values
branches (branches: string seq) : string
envArg (arg: EnvArg) : string
platform (platform: OSPlatform) (isTrue: bool) : string
stageSucceeds (stage: StageContext) : string
--- # ConditionsBuilder
[<Class>]
type ConditionsBuilder =
    new () : ConditionsBuilder
    member inline Combine (condition: BuildStageIsActive, build: BuildConditions) : BuildConditions
    member inline Delay (fn: unit -> StageContext -> bool) : BuildConditions
    member inline Delay (fn: unit -> (StageContext -> bool) list -> (StageContext -> bool) list) : BuildConditions
    member inline For (build: BuildConditions, fn: unit -> StageContext -> bool) : BuildConditions
    member inline For (build: BuildConditions, fn: unit -> (StageContext -> bool) list -> (StageContext -> bool) list) : BuildConditions
    member inline Yield (condition: BuildStageIsActive) : BuildStageIsActive
    member inline Yield () : BuildConditions
    member inline Zero () : BuildConditions
    member inline branch (build: BuildConditions, branch: string) : BuildConditions
    member inline branches (build: BuildConditions, branches: string seq) : BuildConditions
    member inline envVar (build: BuildConditions, name: string, value: string) : BuildConditions
    member inline envVar (build: BuildConditions, name: string) : BuildConditions
    member inline envVar (build: BuildConditions, arg: EnvArg) : BuildConditions
    member inline platform (build: BuildConditions, platform: OSPlatform) : BuildConditions
    member inline platformLinux (build: BuildConditions, ?isTrue: bool) : BuildConditions
    member inline platformOSX (build: BuildConditions, ?isTrue: bool) : BuildConditions
    member inline platformWindows (build: BuildConditions, ?isTrue: bool) : BuildConditions
    member inline when' (build: BuildConditions, stage: StageContext) : BuildConditions
    member inline when' (build: BuildConditions, value: bool) : BuildConditions
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Collects conditions for `whenAll`, `whenAny` and `whenNot`. There is no `cmdArg` operation: System.CommandLine owns arguments now, so a stage that wants to branch on a flag binds it in an `input` CE and tests the bound value with `when'`. ## Constructors
new () : ConditionsBuilder
## Methods
member inline Combine (condition: BuildStageIsActive, build: BuildConditions) : BuildConditions
member inline Delay (fn: unit -> StageContext -> bool) : BuildConditions
member inline Delay (fn: unit -> (StageContext -> bool) list -> (StageContext -> bool) list) : BuildConditions
member inline For (build: BuildConditions, fn: unit -> StageContext -> bool) : BuildConditions
member inline For (build: BuildConditions, fn: unit -> (StageContext -> bool) list -> (StageContext -> bool) list) : BuildConditions
member inline Yield (condition: BuildStageIsActive) : BuildStageIsActive
member inline Yield () : BuildConditions
member inline Zero () : BuildConditions
member inline branch (build: BuildConditions, branch: string) : BuildConditions
Adds a Git branch condition for a single branch name.
member inline branches (build: BuildConditions, branches: string seq) : BuildConditions
Adds a Git branch condition that matches any of the specified branch names.
member inline envVar (build: BuildConditions, name: string, value: string) : BuildConditions
Adds an environment variable condition that checks both name and value. The condition is met only if the environment variable is set and its value matches exactly.
member inline envVar (build: BuildConditions, name: string) : BuildConditions
Adds an environment variable condition by name only. The condition is met if the environment variable is set (non-empty).
member inline envVar (build: BuildConditions, arg: EnvArg) : BuildConditions
Adds an environment variable condition using an `EnvArg` specification.
member inline platform (build: BuildConditions, platform: OSPlatform) : BuildConditions
member inline platformLinux (build: BuildConditions, ?isTrue: bool) : BuildConditions
Adds a condition that is met when running on Linux.
member inline platformOSX (build: BuildConditions, ?isTrue: bool) : BuildConditions
Adds a condition that is met when running on macOS.
member inline platformWindows (build: BuildConditions, ?isTrue: bool) : BuildConditions
Adds a condition that is met when running on Windows.
member inline when' (build: BuildConditions, stage: StageContext) : BuildConditions
Runs a stage as a condition and uses its success as the activation answer. The condition stage runs immediately during condition evaluation and must complete (or fail) before proceeding.
member inline when' (build: BuildConditions, value: bool) : BuildConditions
Adds a literal boolean condition to the builder. The value is typically a boolean bound by an enclosing `input` CE.
--- # EffectfulCondition
[<Class>]
type EffectfulCondition =
    new (description: string, condition: BuildStageIsActive) : EffectfulCondition
    member Description: string
    member Invoke (ctx: StageContext) : bool
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `FSharpFunc` | | Attributes | `[]` `[]` | A condition that performs IO or runs work to answer, made by `Conditions.effectful`. ## Constructors
new (description: string, condition: BuildStageIsActive) : EffectfulCondition
## Properties
member Description: string
## Methods
member Invoke (ctx: StageContext) : bool
--- # ValueConditions
module ValueConditions
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Stage-producing conditions that bind the value they tested. ## Functions and values
whenOk<'a, 'b> (value: Result<'a,'b>) (build: 'a -> StageContext) : StageContext list
Yields the stage `build` makes of an `Ok` value, and nothing for `Error`.
whenSome<'a> (value: 'a option) (build: 'a -> StageContext) : StageContext list
Yields the stage `build` makes of the value, and nothing when there is none. The alternative is `when' value.IsSome` above a `run` that reaches for `value.Value`, which is correct only because of evaluation order — nothing the compiler checks, and a refactor that moves the condition below the step compiles cleanly and throws at run time. The absent case is an empty list, not an inactive stage requiring a name. The push stage exists only when a key was given, and closes over the key itself: ```fsharp let apiKey = Input.optionMaybe "--api-key" let publish = input { let! key = apiKey return pipeline "publish" { stage "pack" { run "dotnet pack -o bin" } whenSome key (fun key -> stage "push" { run (cmd $"dotnet nuget push bin/*.nupkg" |> Cmd.secretOption "--api-key" key) }) } } ```
--- # WhenAllBuilder
[<Class>]
type WhenAllBuilder =
    new () : WhenAllBuilder
    member Run (build: BuildConditions) : BuildStageIsActive
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `ConditionsBuilder` | | Attributes | `[]` | ## Constructors
new () : WhenAllBuilder
## Methods
member Run (build: BuildConditions) : BuildStageIsActive
--- # WhenAnyBuilder
[<Class>]
type WhenAnyBuilder =
    new () : WhenAnyBuilder
    member Run (build: BuildConditions) : BuildStageIsActive
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `ConditionsBuilder` | | Attributes | `[]` | ## Constructors
new () : WhenAnyBuilder
## Methods
member Run (build: BuildConditions) : BuildStageIsActive
--- # WhenEnvBuilder
[<Class>]
type WhenEnvBuilder =
    new () : WhenEnvBuilder
    member inline Delay (fn: unit -> EnvArg -> EnvArg) : BuildEnvInfo
    member Run (build: BuildEnvInfo) : BuildStageIsActive
    member inline Yield (build: BuildEnvInfo) : BuildEnvInfo
    member inline Yield () : BuildEnvInfo
    member inline Zero () : BuildEnvInfo
    member inline acceptValues (build: BuildEnvInfo, values: string list) : EnvArg -> EnvArg
    member inline description (build: BuildEnvInfo, description: string) : EnvArg -> EnvArg
    member inline name (build: BuildEnvInfo, name: string) : EnvArg -> EnvArg
    member inline optional (build: BuildEnvInfo) : EnvArg -> EnvArg
    member inline value (build: BuildEnvInfo, value: string) : EnvArg -> EnvArg
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Describes one environment variable, in place of a wall of `whenEnvVar` overloads. ## Constructors
new () : WhenEnvBuilder
## Methods
member inline Delay (fn: unit -> EnvArg -> EnvArg) : BuildEnvInfo
member Run (build: BuildEnvInfo) : BuildStageIsActive
member inline Yield (build: BuildEnvInfo) : BuildEnvInfo
member inline Yield () : BuildEnvInfo
member inline Zero () : BuildEnvInfo
member inline acceptValues (build: BuildEnvInfo, values: string list) : EnvArg -> EnvArg
Sets multiple accepted values for the environment variable. The condition is met if the environment variable value is one of the accepted values.
member inline description (build: BuildEnvInfo, description: string) : EnvArg -> EnvArg
Sets an optional description for the environment variable.
member inline name (build: BuildEnvInfo, name: string) : EnvArg -> EnvArg
Sets the environment variable name to check.
member inline optional (build: BuildEnvInfo) : EnvArg -> EnvArg
Marks the environment variable as optional. When optional, the condition is met even if the variable is unset.
member inline value (build: BuildEnvInfo, value: string) : EnvArg -> EnvArg
Sets a single required value for the environment variable. The condition is met if the environment variable equals this value.
--- # WhenNotBuilder
[<Class>]
type WhenNotBuilder =
    new () : WhenNotBuilder
    member Run (build: BuildConditions) : BuildStageIsActive
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `ConditionsBuilder` | | Attributes | `[]` | ## Constructors
new () : WhenNotBuilder
## Methods
member Run (build: BuildConditions) : BuildStageIsActive
--- # WhenStageBuilder
[<Class>]
type WhenStageBuilder =
    new (name: string) : WhenStageBuilder
    member Run (build: BuildStage) : BuildStageIsActive
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `StageBuilder` | | Attributes | `[]` | A stage run purely for its result. Everything `stage` accepts is accepted here. ## Constructors
new (name: string) : WhenStageBuilder
## Methods
member Run (build: BuildStage) : BuildStageIsActive
--- # ConsumerTypes
module ConsumerTypes
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | The model types a consumer names in a signature, reachable from `Partas.Build`. Each is an abbreviation of the type of the same name in `Partas.Build.Internal`, and is interchangeable with it. The modules of functions over these types (`StageContext.create`, `PipelineContext.run`, …) stay in `Partas.Build.Internal`; the consumer-facing lookups are in `Partas.Build.StageContext`. ## Declared inside | | | |---|---| | [`BuildCommand`](/Partas.Build/reference/partas-build/partas-build/consumertypes/buildcommand/) | | | [`BuildConditions`](/Partas.Build/reference/partas-build/partas-build/consumertypes/buildconditions/) | | | [`BuildEnvInfo`](/Partas.Build/reference/partas-build/partas-build/consumertypes/buildenvinfo/) | | | [`BuildPipeline`](/Partas.Build/reference/partas-build/partas-build/consumertypes/buildpipeline/) | | | [`BuildStage`](/Partas.Build/reference/partas-build/partas-build/consumertypes/buildstage/) | | | [`BuildStageIsActive`](/Partas.Build/reference/partas-build/partas-build/consumertypes/buildstageisactive/) | | | [`BuildStep`](/Partas.Build/reference/partas-build/partas-build/consumertypes/buildstep/) | | | [`CommandSpec`](/Partas.Build/reference/partas-build/partas-build/consumertypes/commandspec/) | | | [`Measures`](/Partas.Build/reference/partas-build/partas-build/consumertypes/measures/) | | | [`PipelineContext`](/Partas.Build/reference/partas-build/partas-build/consumertypes/pipelinecontext/) | | | [`RuntimeContext`](/Partas.Build/reference/partas-build/partas-build/consumertypes/runtimecontext/) | | | [`StageCondition`](/Partas.Build/reference/partas-build/partas-build/consumertypes/stagecondition/) | | | [`StageContext`](/Partas.Build/reference/partas-build/partas-build/consumertypes/stagecontext/) | | | [`StageIndex`](/Partas.Build/reference/partas-build/partas-build/consumertypes/stageindex/) | | | [`StageParent`](/Partas.Build/reference/partas-build/partas-build/consumertypes/stageparent/) | | | [`Step`](/Partas.Build/reference/partas-build/partas-build/consumertypes/step/) | | | [`StepIndex`](/Partas.Build/reference/partas-build/partas-build/consumertypes/stepindex/) | | --- # BuildCommand
type BuildCommand = BuildCommand
| | | |---|---| | Assembly | `Partas.Build` | --- # BuildConditions
type BuildConditions = BuildConditions
| | | |---|---| | Assembly | `Partas.Build` | --- # BuildEnvInfo
type BuildEnvInfo = BuildEnvInfo
| | | |---|---| | Assembly | `Partas.Build` | --- # BuildPipeline
type BuildPipeline = BuildPipeline
| | | |---|---| | Assembly | `Partas.Build` | --- # BuildStage
type BuildStage = BuildStage
| | | |---|---| | Assembly | `Partas.Build` | --- # BuildStageIsActive
type BuildStageIsActive = BuildStageIsActive
| | | |---|---| | Assembly | `Partas.Build` | --- # BuildStep
type BuildStep = BuildStep
| | | |---|---| | Assembly | `Partas.Build` | --- # CommandSpec
type CommandSpec = CommandSpec
| | | |---|---| | Assembly | `Partas.Build` | --- # Measures
module Measures
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | ## Declared inside | | | |---|---| | [`stepIndex`](/Partas.Build/reference/partas-build/partas-build/consumertypes/measures/stepindex/) | The unit of measure of a `StepIndex`: `0` is a stage's first step | --- # stepIndex
[<Measure>] type stepIndex
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | The unit of measure of a `StepIndex`: `0` is a stage's first step. --- # PipelineContext
type PipelineContext = PipelineContext
| | | |---|---| | Assembly | `Partas.Build` | --- # RuntimeContext
type RuntimeContext = RuntimeContext
| | | |---|---| | Assembly | `Partas.Build` | --- # StageCondition
type StageCondition = StageCondition
| | | |---|---| | Assembly | `Partas.Build` | --- # StageContext
type StageContext = StageContext
| | | |---|---| | Assembly | `Partas.Build` | --- # StageIndex
type StageIndex = StageIndex
| | | |---|---| | Assembly | `Partas.Build` | ## Properties
member IsCondition: bool
member IsStage: bool
member IsStep: bool
--- # StageParent
type StageParent = StageParent
| | | |---|---| | Assembly | `Partas.Build` | ## Properties
member IsPipeline: bool
member IsStage: bool
--- # Step
type Step = Step
| | | |---|---| | Assembly | `Partas.Build` | ## Properties
member IsOperation: bool
member IsStepFn: bool
member IsStepOfStage: bool
--- # StepIndex
type StepIndex = StepIndex
| | | |---|---| | Assembly | `Partas.Build` | | Implements | `IConvertible`, `ISpanFormattable`, `IFormattable`, `ISpanParsable`, `IParsable`, `IUtf8SpanFormattable`, `IUtf8SpanParsable` | --- # DependenciesBuilder
module DependenciesBuilder
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | ## Declared inside | | | |---|---| | [`DependenciesBuilder`](/Partas.Build/reference/partas-build/partas-build/dependenciesbuilder/dependenciesbuilder/) | Applicative computation expression collecting the CLI inputs and producers a value depends on | ## Functions and values
dependency: DependenciesBuilder
--- # DependenciesBuilder
[<Class>]
type DependenciesBuilder =
    new () : DependenciesBuilder
    member inline BindReturn<'A, 'B> (spec: DependencySpec<'A>, fn: 'A -> 'B) : DependencySpec<'B>
    member inline MergeSources<'A, 'B> (a: DependencySpec<'A>, b: DependencySpec<'B>) : DependencySpec<'A * 'B>
    member MergeSources3<'A, 'B, 'C> (a: DependencySpec<'A>, b: DependencySpec<'B>, c: DependencySpec<'C>) : DependencySpec<'A * 'B * 'C>
    member MergeSources4<'A, 'B, 'C, 'D> (a: DependencySpec<'A>, b: DependencySpec<'B>, c: DependencySpec<'C>, d: DependencySpec<'D>) : DependencySpec<'A * 'B * 'C * 'D>
    member MergeSources5<'A, 'B, 'C, 'D, 'E> (a: DependencySpec<'A>, b: DependencySpec<'B>, c: DependencySpec<'C>, d: DependencySpec<'D>, e: DependencySpec<'E>) : DependencySpec<'A * 'B * 'C * 'D * 'E>
    member inline Return<'T> (value: 'T) : DependencySpec<'T>
    member inline ReturnFrom<'T> (spec: DependencySpec<'T>) : DependencySpec<'T>
    member inline Source<'T> (spec: DependencySpec<'T>) : DependencySpec<'T>
    member inline Source<'T> (operation: Operation<'T>) : DependencySpec<'T>
    member inline Source<'T> (producer: Producer<'T>) : DependencySpec<'T>
| | | |---|---| | Assembly | `Partas.Build` | Applicative computation expression collecting the CLI inputs and producers a value depends on. `Bind` is deliberately absent: a sequential `let!` would let the second source depend on the first's value, which cannot be known before parsing, so the input set would not be statically readable. Omitting it makes that a compile error (`FS0708`) instead of a silently incomplete option set. Bind every source in one `let!`/`and!` group. ## Constructors
new () : DependenciesBuilder
## Methods
member inline BindReturn<'A, 'B> (spec: DependencySpec<'A>, fn: 'A -> 'B) : DependencySpec<'B>
member inline MergeSources<'A, 'B> (a: DependencySpec<'A>, b: DependencySpec<'B>) : DependencySpec<'A * 'B>
member MergeSources3<'A, 'B, 'C> (a: DependencySpec<'A>, b: DependencySpec<'B>, c: DependencySpec<'C>) : DependencySpec<'A * 'B * 'C>
member MergeSources4<'A, 'B, 'C, 'D> (a: DependencySpec<'A>, b: DependencySpec<'B>, c: DependencySpec<'C>, d: DependencySpec<'D>) : DependencySpec<'A * 'B * 'C * 'D>
member MergeSources5<'A, 'B, 'C, 'D, 'E> (a: DependencySpec<'A>, b: DependencySpec<'B>, c: DependencySpec<'C>, d: DependencySpec<'D>, e: DependencySpec<'E>) : DependencySpec<'A * 'B * 'C * 'D * 'E>
member inline Return<'T> (value: 'T) : DependencySpec<'T>
member inline ReturnFrom<'T> (spec: DependencySpec<'T>) : DependencySpec<'T>
member inline Source<'T> (spec: DependencySpec<'T>) : DependencySpec<'T>
member inline Source<'T> (operation: Operation<'T>) : DependencySpec<'T>
member inline Source<'T> (producer: Producer<'T>) : DependencySpec<'T>
--- # DependencyPlan
type DependencyPlan =
    {
        Placements: ProducerPlacement list
    }
| | | |---|---| | Assembly | `Partas.Build` | The static producer graph for all pipelines a command will invoke. ## Fields
Placements: ProducerPlacement list
## Functions and values
validate (pipelines: PipelineContext list) : Result<DependencyPlan,string>
Validates and locates producer work before any producer callback is invoked. Each pipeline receives its own placement set; a second invocation validates afresh. The supported scopes are sequential: a producer stage, whether listed explicitly or placed before its first consumer, must sit in a scope that is neither `parallel'` nor `shuffleExecuteSequence`, at any nesting depth. Both arrangements are rejected here, naming the producer and the scope. An author who needs a parallel scope to consume a producer lists that producer explicitly ahead of the scope; a consumer inside the scope then reads the value the enclosing sequential scope published.
--- # DependencySpec
type DependencySpec<'T> =
    {
        Requires: ProducerRef list
        Inputs: ActionInput list
        Read: ProducerValues -> Result<'T,string>
    }
| | | |---|---| | Assembly | `Partas.Build` | The prerequisites of one piece of work, and the typed value their results read as. Composition is applicative: `Read` receives the values a scope has published. `Read` answers `Error` naming the first prerequisite whose published value is unavailable or of another type. An exception out of `Read` comes from a function the caller supplied. ## Fields
Requires: ProducerRef list
The producers required before the dependent work runs, in declaration order.
Inputs: ActionInput list
The CLI inputs the required producers declare.
Read: ProducerValues -> Result<'T,string>
## Functions and values
empty: DependencySpec<unit>
map<'T, 'U> (fn: 'T -> 'U) (spec: DependencySpec<'T>) : DependencySpec<'U>
The same prerequisites, read as `fn` applied to their value. | Parameter | | |---|---| | `fn` | | | `spec` | |
map2<'T, 'U, 'V> (fn: 'T -> 'U -> 'V) (first: DependencySpec<'T>) (second: DependencySpec<'U>) : DependencySpec<'V>
The prerequisites and inputs of both specifications, read as `fn` applied to both values. The first unavailable prerequisite, in declaration order, is the one reported. | Parameter | | |---|---| | `fn` | | | `first` | | | `second` | |
require<'T> (producer: Producer<'T>) : DependencySpec<'T>
A specification requiring `producer` and reading its result. | Parameter | | |---|---| | `producer` | |
sequence<'T> (dependencies: DependencySpec<'T> seq) : DependencySpec<'T list>
traverse<'T, 'a> (fn: 'T -> 'a) (dependencies: DependencySpec<'T> seq) : DependencySpec<'a list>
zip<'T, 'U> (first: Producer<'T>) (second: Producer<'U>) : DependencySpec<'T * 'U>
A specification requiring both producers and reading their results as a pair.
zipDependencies<'A, 'B> (first: DependencySpec<'A>) (second: DependencySpec<'B>) : DependencySpec<'A * 'B>
--- # EnvArg
type EnvArg =
    {
        Name: string
        Values: string list
        Description: string option
        IsOptional: bool
    }
| | | |---|---| | Assembly | `Partas.Build` | ## Fields
Name: string
Values: string list
Description: string option
IsOptional: bool
## Functions and values
create (name: string) : EnvArg
withDescription (description: string) (envArg: EnvArg) : EnvArg
withIsOptional (isOptional: bool) (envArg: EnvArg) : EnvArg
withName (name: string) (envArg: EnvArg) : EnvArg
withValues (values: string list) (envArg: EnvArg) : EnvArg
--- # ErrorHandling
module ErrorHandling
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | ## Declared inside | | | |---|---| | [`FailureCause`](/Partas.Build/reference/partas-build/partas-build/errorhandling/failurecause/) | Why a step failed, with the evidence the failure carries | | [`OperationFailedException`](/Partas.Build/reference/partas-build/partas-build/errorhandling/operationfailedexception/) | Carries a [`FailureCause`](/Partas.Build/reference/partas-build/partas-build/errorhandling/failurecause/) out of the operation that produced it | | [`PipelineCancelledException`](/Partas.Build/reference/partas-build/partas-build/errorhandling/pipelinecancelledexception/) | | | [`PipelineFailedException`](/Partas.Build/reference/partas-build/partas-build/errorhandling/pipelinefailedexception/) | | | [`StageOutcome`](/Partas.Build/reference/partas-build/partas-build/errorhandling/stageoutcome/) | How a stage ended | | [`StageSoftCancelledException`](/Partas.Build/reference/partas-build/partas-build/errorhandling/stagesoftcancelledexception/) | | | [`StepOutcome`](/Partas.Build/reference/partas-build/partas-build/errorhandling/stepoutcome/) | How a step of deferred work ended | | [`StepSoftCancelledException`](/Partas.Build/reference/partas-build/partas-build/errorhandling/stepsoftcancelledexception/) | | --- # FailureCause
type FailureCause =
    | Command of command: string * exitCode: int * captured: CommandResult voption
    | Start of executable: string * error: exn
    | Raised of error: exn
    | TimedOut
    | Reported of message: string
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Why a step failed, with the evidence the failure carries. A cause stays structured up to the print site, where `describe` renders it. ## Cases
Command of command: string * exitCode: int * captured: CommandResult voption
A command completed with an exit code the stage rejects. `command` is the log form, with secrets masked. `captured` holds the child's raw output where the operation asked for capture.
Start of executable: string * error: exn
A command failed to start. The executable name and the platform's exception are the whole of the evidence a process that never ran leaves behind.
Raised of error: exn
An exception escaped the operation, a parsing failure among them.
TimedOut
The executing scope's own timeout expired.
Reported of message: string
A failure the operation reported itself.
## Functions and values
describe (cause: FailureCause) : string
The line a failed step prints and annotates with. A captured failure appends the child's own text — stderr where the command used it, stdout otherwise — on a line of its own, the way a stage's capture is lifted.
summarise (cause: FailureCause) : string
The first line of `describe`, for a report with room for one line. A multi-line capture loses every line after the first: this is a truncation, not a summary of what the later lines said.
toException (cause: FailureCause) : exn
The exception `cause` travels as. An exception that escaped an operation is itself; every other cause travels inside an [`OperationFailedException`](/Partas.Build/reference/partas-build/partas-build/errorhandling/operationfailedexception/), which keeps it readable at the catch site.
## Properties
member IsCommand: bool
member IsRaised: bool
member IsReported: bool
member IsStart: bool
member IsTimedOut: bool
--- # OperationFailedException
[<Class>]
type OperationFailedException =
    new (cause: FailureCause) : OperationFailedException
    member Cause: FailureCause
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `Exception` | Carries a [`FailureCause`](/Partas.Build/reference/partas-build/partas-build/errorhandling/failurecause/) out of the operation that produced it. Its own `Message` matches each case of `cause` by hand rather than through `describe`: the two are written separately and can drift. ## Constructors
## Properties
member Cause: FailureCause
--- # PipelineCancelledException
[<Class>]
type PipelineCancelledException =
    new (msg: string) : PipelineCancelledException
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `Exception` | ## Constructors
new (msg: string) : PipelineCancelledException
--- # PipelineFailedException
[<Class>]
type PipelineFailedException =
    new (msg: string, ex: exn) : PipelineFailedException
    new (msg: string) : PipelineFailedException
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `Exception` | ## Constructors
new (msg: string, ex: exn) : PipelineFailedException
new (msg: string) : PipelineFailedException
--- # StageOutcome
[<Struct>]
type StageOutcome =
    | Succeeded
    | Skipped
    | Failed of error: string
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` `[]` | How a stage ended. ## Cases
Succeeded
Skipped
Inactive: a condition on the stage was false.
Failed of error: string
`error` is the message of the first exception a step raised, falling back to the first line of the first cause the scope recorded.
## Properties
member IsFailed: bool
member IsSkipped: bool
member IsSucceeded: bool
--- # StageSoftCancelledException
[<Class>]
type StageSoftCancelledException =
    new (msg: string) : StageSoftCancelledException
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `Exception` | ## Constructors
new (msg: string) : StageSoftCancelledException
--- # StepOutcome
[<Struct>]
type StepOutcome =
    | Completed
    | Failed of cause: FailureCause
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` `[]` | How a step of deferred work ended. ## Cases
Completed
Failed of cause: FailureCause
## Properties
member IsCompleted: bool
member IsFailed: bool
--- # StepSoftCancelledException
[<Class>]
type StepSoftCancelledException =
    new (msg: string) : StepSoftCancelledException
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `Exception` | ## Constructors
new (msg: string) : StepSoftCancelledException
--- # ExecutionState
type ExecutionState =
    {
        sync: obj
        values: ProducerValues
    }
| | | |---|---| | Assembly | `Partas.Build` | What one pipeline invocation has published, and the boundary at which a scope discards it. Invocation-local: a run empties the state before its first stage, and a second invocation of the same pipeline value executes its producers again. Publication is sequential, within the scopes `DependencyPlan.validate` admits: a producer stage sits outside every `parallel'` and `shuffleExecuteSequence` scope. Consumers running in parallel read values completed before their scope began. ## Fields
sync: obj
values: ProducerValues
## Functions and values
clear: ExecutionState -> unit
Discards every published value. An invocation starts from here.
contains (id: ProducerId) (state: ExecutionState) : bool
create () : ExecutionState
publish (producerRef: ProducerRef) (value: obj) : ExecutionState -> unit
Publishes value as the result of producer.
resetTo (snapshot: ProducerValues) : ExecutionState -> unit
Restores the values as snapshot held them. An attempt boundary: a scope that snapshots before its first attempt discards, on every further attempt, what the previous attempt published.
values (state: ExecutionState) : ProducerValues
The values available to the work running now.
--- # ExitCode
module ExitCode
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | The process exit codes a command invocation ends with. `UsageError` covers every failure to parse or validate the command line, dependency validation included, and guarantees no stage ran. ## Functions and values
[<Literal>] Cancelled: int = 130
The run was cancelled, by a timeout of the pipeline or by the invocation's cancellation token.
[<Literal>] Failure: int = 1
A stage failed, or the invocation raised an exception.
[<Literal>] Success: int = 0
Every pipeline succeeded, or the invocation printed help, a version or an --explain tree.
[<Literal>] UsageError: int = 2
The command line did not parse or validate, or the pipelines it selects failed dependency validation.
--- # ExplainedCondition
type ExplainedCondition =
    {
        Reason: string voption
        Effect: string voption
        State: ExplainedConditionState
    }
| | | |---|---| | Assembly | `Partas.Build` | One condition of a stage, as `--explain` answered it. ## Fields
Reason: string voption
The text attributing a skip to the condition.
Effect: string voption
The description the condition is marked effectful with.
--- # ExplainedConditionState
type ExplainedConditionState =
    | Held
    | Failed
    | Unevaluated
    | Threw of message: string
    | NotReached
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | The answer `--explain` records for one condition of a stage. ## Cases
Held
Failed
Unevaluated
Marked effectful and left uncalled by a static explanation.
Threw of message: string
The condition raised an exception.
NotReached
An earlier condition failed or threw, and the stage's IsActive stops at it.
## Properties
member IsFailed: bool
member IsHeld: bool
member IsNotReached: bool
member IsThrew: bool
member IsUnevaluated: bool
--- # ExplainedPipeline
type ExplainedPipeline =
    {
        Name: string
        Description: string voption
        Stages: ExplainedStage list
        Post: ExplainedStage list
    }
| | | |---|---| | Assembly | `Partas.Build` | A pipeline as `--explain` describes it. ## Fields
Name: string
Description: string voption
Stages: ExplainedStage list
Post: ExplainedStage list
--- # ExplainedStage
type ExplainedStage =
    {
        Name: string
        Produces: string voption
        Needs: string list
        Status: ExplainedStatus
        Conditions: ExplainedCondition list
        Steps: ExplainedStep list
    }
| | | |---|---| | Assembly | `Partas.Build` | A stage as `--explain` describes it. ## Fields
Name: string
Produces: string voption
The producer the stage declares.
Needs: string list
The producers the stage requires.
Status: ExplainedStatus
Conditions: ExplainedCondition list
Steps: ExplainedStep list
--- # ExplainedStatus
type ExplainedStatus =
    | Active
    | Skipped of reason: string voption
    | Unevaluated of effects: string list
    | Errored of message: string
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Whether a stage would run, as its conditions answered. ## Cases
Active
Skipped of reason: string voption
A condition failed; reason is that condition's own.
Unevaluated of effects: string list
Every evaluated condition held, and the conditions listed were left uncalled.
Errored of message: string
A condition raised an exception with this message.
## Properties
member IsActive: bool
member IsErrored: bool
member IsSkipped: bool
member IsUnevaluated: bool
--- # ExplainedStep
type ExplainedStep =
    | Step of index: int * label: string voption
    | Operation of index: int * label: string voption
    | Stage of index: int * stage: ExplainedStage
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | A step as `--explain` describes it; `index` counts from zero, as a run result's `step` and `path` do. A label is the step's log form, with every secret masked. ## Cases
Step of index: int * label: string voption
Operation of index: int * label: string voption
Stage of index: int * stage: ExplainedStage
## Properties
member IsOperation: bool
member IsStage: bool
member IsStep: bool
--- # ExplainMode
[<Struct>]
type ExplainMode =
    | Evaluated
    | Static
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` `[]` | How `--explain` answers a condition that performs IO or runs work to answer. ## Cases
Evaluated
Every condition is called, whenBranch starting git and whenStage running its stage.
Static
A condition marked Conditions.effectful is reported unevaluated and left uncalled; every other condition is called.
## Properties
member IsEvaluated: bool
member IsStatic: bool
--- # FailureContext
type FailureContext =
    {
        Scope: string
        Address: ScopeAddress
        Outcome: StageOutcome
        Failures: StepFailure list
        Exceptions: exn list
        Nested: ScopeReport list
        Published: ProducerValues
    }
| | | |---|---| | Assembly | `Partas.Build` | What one failed execution of a scope hands the handlers registered on it. The scope's own result, as its report records it, alongside the producer values the invocation holds. A handler reads why the scope failed from `Primary` and `Secondary` rather than from the text the run printed. ## Fields
Scope: string
The scope's name.
Address: ScopeAddress
Where the scope sits in the run.
Outcome: StageOutcome
What the scope did.
Failures: StepFailure list
The causes this execution of the scope recorded, the primary first.
Exceptions: exn list
The exceptions offered to the scope containing this one.
Nested: ScopeReport list
The reports of the scopes this one ran, in the order they finished.
Published: ProducerValues
The values the invocation has published and still holds.
## Functions and values
[<Literal>] HandlerLabel: string = "onFailure"
The StepFailure.Label of a cause a handler itself left.
handlerReport (context: FailureContext) (raised: StepFailure list) : ScopeReport
The report a scope leaves for `raised`, the causes its own handlers produced. `Propagates` is false: a handler's failure is evidence beside the scope's own cause, and what reaches the scope containing it stays the cause it failed with. A stage records these on the report its steps already fill; this is how a pipeline, whose report no step fills, records the same thing. | Parameter | | |---|---| | `context` | | | `raised` | |
ofReport (published: ProducerValues) (report: ScopeReport) : FailureContext
The context the handlers of the scope `report` covers receive. | Parameter | | |---|---| | `published` | | | `report` | |
runHandlers (handlers: FailureHandler list) (context: FailureContext) : StepFailure list
Runs `handlers` in registration order, answering the causes they themselves left. An exception out of a handler is one more cause of the same scope, recorded after the scope's own and leaving the primary where it was. The handlers registered after it still run, and each handler is entered at most once. | Parameter | | |---|---| | `handlers` | | | `context` | |
## Properties
member Primary: StepFailure voption
The failure the scope reports as its result.
member Secondary: StepFailure list
The causes of the same execution beyond the primary, in the order they were recorded.
--- # FailureHandler
type FailureHandler = FailureContext -> unit
| | | |---|---| | Assembly | `Partas.Build` | Runs when the scope it is registered on fails. Synchronous: nothing bounds how long it runs, and there is no separate cleanup operation or cleanup budget for it to run under. `onFailure` registers one on a stage and on a pipeline; the command builder carries no equivalent, so a failure of input resolution or CLI parsing reaches no handler. --- # InputsBuilder
module InputsBuilder
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | ## Declared inside | | | |---|---| | [`InputsBuilder`](/Partas.Build/reference/partas-build/partas-build/inputsbuilder/inputsbuilder/) | Applicative computation expression collecting the CLI inputs a value depends on | ## Functions and values
input: InputsBuilder
Binds CLI inputs to a value — usually a stage or a pipeline — whose inputs a command registers. Every source is bound in one `let!`/`and!` group. The result is an `InputSpec`: yielded into a `stage`, `pipeline` or `command`, its options appear in that command's `--help`. ```fsharp module Options = let configuration = Input.option "--configuration" |> Input.alias "-c" |> Input.def "Release" let quick = Input.option "--quick" |> Input.alias "-q" let build = input { let! configuration = Options.configuration and! quick = Options.quick return stage "build" { when' (not quick) "--quick is set" run (cmd $"dotnet build -c {configuration}") } } ```
inputs: InputsBuilder
:::warning Deprecated Use `input` instead. :::
--- # InputsBuilder
[<Class>]
type InputsBuilder =
    new () : InputsBuilder
    member inline BindReturn<'A, 'B> (spec: InputSpec<'A>, fn: 'A -> 'B) : InputSpec<'B>
    member inline MergeSources<'A, 'B> (a: InputSpec<'A>, b: InputSpec<'B>) : InputSpec<'A * 'B>
    member inline MergeSources3<'A, 'B, 'C> (a: InputSpec<'A>, b: InputSpec<'B>, c: InputSpec<'C>) : InputSpec<'A * 'B * 'C>
    member inline MergeSources4<'A, 'B, 'C, 'D> (a: InputSpec<'A>, b: InputSpec<'B>, c: InputSpec<'C>, d: InputSpec<'D>) : InputSpec<'A * 'B * 'C * 'D>
    member inline MergeSources5<'A, 'B, 'C, 'D, 'E> (a: InputSpec<'A>, b: InputSpec<'B>, c: InputSpec<'C>, d: InputSpec<'D>, e: InputSpec<'E>) : InputSpec<'A * 'B * 'C * 'D * 'E>
    member inline Return<'T> (value: 'T) : InputSpec<'T>
    member inline ReturnFrom<'T> (spec: InputSpec<'T>) : InputSpec<'T>
    member inline Source<'T> (spec: InputSpec<'T>) : InputSpec<'T>
    member inline Source<'T> (input: ActionInput<'T>) : InputSpec<'T>
| | | |---|---| | Assembly | `Partas.Build` | Applicative computation expression collecting the CLI inputs a value depends on. `Bind` is deliberately absent: a sequential `let!` would let the second source depend on the first's value, which cannot be known before parsing, so the input set would not be statically readable. Omitting it makes that a compile error (`FS0708`) instead of a silently incomplete option set. Bind every source in one `let!`/`and!` group. ## Constructors
new () : InputsBuilder
## Methods
member inline BindReturn<'A, 'B> (spec: InputSpec<'A>, fn: 'A -> 'B) : InputSpec<'B>
member inline MergeSources<'A, 'B> (a: InputSpec<'A>, b: InputSpec<'B>) : InputSpec<'A * 'B>
member inline MergeSources3<'A, 'B, 'C> (a: InputSpec<'A>, b: InputSpec<'B>, c: InputSpec<'C>) : InputSpec<'A * 'B * 'C>
member inline MergeSources4<'A, 'B, 'C, 'D> (a: InputSpec<'A>, b: InputSpec<'B>, c: InputSpec<'C>, d: InputSpec<'D>) : InputSpec<'A * 'B * 'C * 'D>
member inline MergeSources5<'A, 'B, 'C, 'D, 'E> (a: InputSpec<'A>, b: InputSpec<'B>, c: InputSpec<'C>, d: InputSpec<'D>, e: InputSpec<'E>) : InputSpec<'A * 'B * 'C * 'D * 'E>
member inline Return<'T> (value: 'T) : InputSpec<'T>
member inline ReturnFrom<'T> (spec: InputSpec<'T>) : InputSpec<'T>
member inline Source<'T> (spec: InputSpec<'T>) : InputSpec<'T>
member inline Source<'T> (input: ActionInput<'T>) : InputSpec<'T>
--- # InputSpec
module InputSpec
| | | |---|---| | Assembly | `Partas.Build` | ## Functions and values
empty: InputSpec<unit>
map<'a, 'b> (f: 'a -> 'b) (s: InputSpec<'a>) : InputSpec<'b>
map2<'a, 'b, 'c> (f: 'a -> 'b -> 'c) (a: InputSpec<'a>) (b: InputSpec<'b>) : InputSpec<'c>
ofInput<'T> (input: ActionInput<'T>) : InputSpec<'T>
ret<'a> (v: 'a) : InputSpec<'a>
sequence<'T> (specs: InputSpec<'T> seq) : InputSpec<'T list>
Collapses a sequence of specs into one spec of a list, unioning their inputs. This is what lets a collection of ready-made blocks - a stage per project, say - be yielded as a unit.
traverse<'T, 'U> (fn: 'T -> InputSpec<'U>) (items: 'T seq) : InputSpec<'U list>
union (inputs: ActionInput list list) : ActionInput list
Concatenates input sets, keeping the first occurrence of each input. ActionInput has no custom equality, so this compares by reference: the same let-bound option declared by two specs collapses to one, while two separately created options do not.
## Extension members
member inline when' (spec: InputSpec<BuildStage>, value: bool) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline when' (spec: InputSpec<BuildStage>, value: bool, skipReason: string) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline when' (spec: InputSpec<BuildStage>, stage: StageContext) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenBranch (spec: InputSpec<BuildStage>, branch: string) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenBranches (spec: InputSpec<BuildStage>, branches: string seq) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenEnvVar (spec: InputSpec<BuildStage>, arg: EnvArg) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenEnvVar (spec: InputSpec<BuildStage>, name: string) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenEnvVar (spec: InputSpec<BuildStage>, name: string, value: string) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenLinux (spec: InputSpec<BuildStage>, ?isTrue: bool) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenOSX (spec: InputSpec<BuildStage>, ?isTrue: bool) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenPlatform (spec: InputSpec<BuildStage>, platform: OSPlatform) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member inline whenWindows (spec: InputSpec<BuildStage>, ?isTrue: bool) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
--- # Operation
[<Struct>]
type Operation<'T> =
    {
        Execute: RuntimeContext -> Async<'T>
    }
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Work deferred until a stage executes it. `Execute` is a reader over the executing step's runtime context: it runs when the step it belongs to runs, and building one is pure. A failure travels as an [`OperationFailedException`](/Partas.Build/reference/partas-build/partas-build/errorhandling/operationfailedexception/) carrying a [`FailureCause`](/Partas.Build/reference/partas-build/partas-build/errorhandling/failurecause/), which `Operation.toStepOutcome` reads as a [`StepOutcome`](/Partas.Build/reference/partas-build/partas-build/errorhandling/stepoutcome/). ## Fields
Execute: RuntimeContext -> Async<'T>
## Functions and values
bind<'T, 'U> (fn: 'T -> Operation<'U>) (operation: Operation<'T>) : Operation<'U>
The operation `fn` answers for the first one's value, run after it. Sequencing is local to the executing scope: it orders work inside one step and declares no stage dependency.
fail<'T> (cause: FailureCause) : 'T
Raises `cause` out of the operation running it.
map<'T, 'U> (fn: 'T -> 'U) (operation: Operation<'T>) : Operation<'U>
The same work, answering `fn` applied to its value.
ofAsync<'T> (work: Async<'T>) : Operation<'T>
An operation executing `work` under the stage's runtime context.
ofTaskFactory<'T> (factory: unit -> Task<'T>) : Operation<'T>
An operation executing the task `factory` builds. A definition carries the factory itself, and the step that runs applies it.
parallelSequence<'T> (operations: Operation<'T> seq) : Operation<'T list>
parallelSequenceWith<'T> (maxParallelism: int) (operations: Operation<'T> seq) : Operation<'T list>
parallelTraverse<'T, 'a> (fn: 'T -> 'a) (operations: Operation<'T> seq) : Operation<'a list>
parallelTraverseWith<'T, 'a> (fn: 'T -> 'a) (maxParallelism: int) (operations: Operation<'T> seq) : Operation<'a list>
ret<'T> (value: 'T) : Operation<'T>
An operation answering `value` as it stands.
sequence<'T> (operations: Operation<'T> seq) : Operation<'T list>
Collapses a sequence of operations into one operation of a list
toStepOutcome (operation: Operation<unit>) (context: RuntimeContext) : Async<StepOutcome>
Runs `operation` as a step and classifies what escapes it. A reported cause, and an exception the operation let through, both become [`StepOutcome`](/Partas.Build/reference/partas-build/partas-build/errorhandling/stepoutcome/).`Failed`; a parsing failure arrives that way, holding the exception itself. Cancellation propagates, along with the pipeline and soft-cancellation exceptions, to the runner that classifies it: only the runner holds the tokens that say whose it was.
traverse<'T, 'a> (fn: 'T -> 'a) (operations: Operation<'T> seq) : Operation<'a list>
--- # Operations
module Operations
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Commands as operations: deferred, stage-configured, and explicit about their failure policy. ## Functions and values
annotate (annotation: Annotation) : Operation<unit>
Emits a structured diagnostic when the stage runs, independently of its output sink or verbosity. In GitHub Actions this creates an annotation; locally it prints a readable diagnostic. An error annotation does not fail the operation. Use the stage's failure policy to fail execution.
attemptCapture (command: Cmd) : Operation<CommandResult>
Runs `command` and answers the result of every process that completed. Every exit code is a result here, the rejected ones included, so branching on one is the caller's. Every `CommandResult` is a process that ran to completion — a failure to start and a cancellation are outcomes of their own — so a fallback keyed on an exit code applies to completed processes alone.
execute (command: Cmd) : Operation<unit>
Runs `command`, streaming its output the way the stage routes it. The exit code is checked against the acceptable set the stage resolves, and an unacceptable one fails the operation naming the command and the code. The output went to the stage's sink as it arrived, so the failure carries the command and the code alone.
executeCapture (command: Cmd) : Operation<CommandResult>
Runs `command` and answers its raw stdout and stderr. An exit code the stage rejects fails the operation, and the captured result travels with the failure as its evidence. Raw text is application data and can hold secrets, so printing a successful capture is the caller's decision.
--- # OutputHandling
module OutputHandling
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | ## Declared inside | | | |---|---| | [`Markup`](/Partas.Build/reference/partas-build/partas-build/outputhandling/markup/) | | | [`OutputCapture`](/Partas.Build/reference/partas-build/partas-build/outputhandling/outputcapture/) | The lines a stage held back, in the order they were written | | [`StageOutput`](/Partas.Build/reference/partas-build/partas-build/outputhandling/stageoutput/) | Where the output of a stage's steps goes | | [`StdStream`](/Partas.Build/reference/partas-build/partas-build/outputhandling/stdstream/) | Which of a step's two streams a line of output came from | | [`Verbosity`](/Partas.Build/reference/partas-build/partas-build/outputhandling/verbosity/) | | --- # Markup
module Markup
| | | |---|---| | Assembly | `Partas.Build` | ## Functions and values
bold (str: string) : string
escape (str: string) : string
green (str: string) : string
grey (str: string) : string
lime (str: string) : string
red (str: string) : string
turquoise2 (str: string) : string
turquoise4 (str: string) : string
yellow (str: string) : string
--- # OutputCapture
type OutputCapture =
    {
        lines: ResizeArray<struct (StdStream * string)>
    }
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | The lines a stage held back, in the order they were written. One capture is shared by every step of the stage that declared it and by its sub-stages, so it locks: steps run in parallel, and a process's two streams are read on two threads of their own. ## Fields
lines: ResizeArray<struct (StdStream * string)>
## Functions and values
add (stream: StdStream) (line: string) (outputCapture: OutputCapture) : unit
clear: OutputCapture -> unit
count: OutputCapture -> int
create () : OutputCapture
entries: OutputCapture -> struct (StdStream * string) list
Everything written, each line paired with the stream it arrived on, in write order.
errorText: OutputCapture -> string
errors: OutputCapture -> string list
Only what went to stderr.
failureText (oc: OutputCapture) : string
What a failure lifts. stderr when the process used it, and everything otherwise: a test runner that reports its failures on stdout is the ordinary case, and lifting only stderr there would lift nothing at all.
isEmpty: OutputCapture -> bool
lines: OutputCapture -> string list
Everything written, both streams, interleaved in the order it arrived.
text: OutputCapture -> string
trimTo (count: int) : OutputCapture -> unit
Discards every line after the first `count`. A `count` at or above `Count` leaves the capture as it is. | Parameter | | |---|---| | `count` | |
--- # StageOutput
type StageOutput =
    | Console
    | Silent
    | Captured of capture: OutputCapture
    | Redirect of write: StdStream -> string -> unit
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Where the output of a stage's steps goes. This is the steps' output only — what the child processes write, and what `echo` says. The pipeline's own log (the stage rules, the command lines, the timings) always goes to the console; `verbosity` is what controls that. ## Cases
Console
Silent
Dropped.
Captured of capture: OutputCapture
Held, and lifted into the error message if a step fails.
Redirect of write: StdStream -> string -> unit
Handed to a function, line by line, as it arrives.
## Properties
member IsCaptured: bool
member IsConsole: bool
member IsRedirect: bool
member IsSilent: bool
--- # StdStream
[<Struct>]
type StdStream =
    | Out
    | Err
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` `[]` | Which of a step's two streams a line of output came from. ## Cases
Out
Err
## Properties
member IsErr: bool
member IsOut: bool
--- # Verbosity
[<Struct>]
type Verbosity =
    | Quiet
    | Normal
    | Verbose
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` `[]` | ## Cases
Quiet
Normal
Verbose
## Properties
static member Default: Verbosity
member IsNormal: bool
member IsQuiet: bool
member IsVerbose: bool
--- # PipelineBuilder
module PipelineBuilder
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | ## Declared inside | | | |---|---| | [`PipelineBuilder`](/Partas.Build/reference/partas-build/partas-build/pipelinebuilder/pipelinebuilder/) | Builds a pipeline from stages | ## Functions and values
pipeline (name: string) : PipelineBuilder
Builds a named pipeline: stages run in order, under the settings the pipeline gives them. Run it through a `command`, which registers the CLI inputs its stages declare, or directly with `PipelineContext.run` when it declares none. ```fsharp pipeline "ci" { workingDir __SOURCE_DIRECTORY__ timeout 600 stage "restore" { run "dotnet restore" } stage "build" { run "dotnet build --no-restore" } post [ stage "report" { echo "done" } ] } ```
--- # PipelineBuilder
[<Class>]
type PipelineBuilder =
    new (name: string) : PipelineBuilder
    member Combine (spec: InputSpec<BuildPipeline>, stage: StageContext) : InputSpec<BuildPipeline>
    member Combine (spec: InputSpec<BuildPipeline>, rest: BuildPipeline) : InputSpec<BuildPipeline>
    member inline Combine (build: InputSpec<BuildPipeline>, rest: InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
    member inline Combine (spec: InputSpec<StageContext>, rest: InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
    member Combine (spec: InputSpec<StageContext>, build: BuildPipeline) : InputSpec<BuildPipeline>
    member Combine (build: BuildPipeline, rest: InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
    member Combine (stage: StageContext, spec: InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
    member inline Combine (stage: StageContext, build: BuildPipeline) : BuildPipeline
    member inline Combine (build: BuildPipeline, rest: BuildPipeline) : BuildPipeline
    member inline Delay (fn: unit -> InputSpec<StageContext>) : InputSpec<BuildPipeline>
    member inline Delay (fn: unit -> InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
    member inline Delay (fn: unit -> StageContext) : BuildPipeline
    member inline Delay (fn: unit -> PipelineContext -> PipelineContext) : BuildPipeline
    member inline Delay (fn: unit -> unit) : unit
    member inline For (spec: InputSpec<BuildPipeline>, fn: unit -> InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
    member For (build: BuildPipeline, fn: unit -> InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
    member inline For (spec: InputSpec<BuildPipeline>, fn: unit -> InputSpec<StageContext>) : InputSpec<BuildPipeline>
    member For (spec: InputSpec<BuildPipeline>, fn: unit -> StageContext) : InputSpec<BuildPipeline>
    member For (spec: InputSpec<BuildPipeline>, fn: unit -> PipelineContext -> PipelineContext) : InputSpec<BuildPipeline>
    member For (build: BuildPipeline, fn: unit -> InputSpec<StageContext>) : InputSpec<BuildPipeline>
    member inline For (build: BuildPipeline, fn: unit -> StageContext) : BuildPipeline
    member inline For (build: BuildPipeline, fn: unit -> PipelineContext -> PipelineContext) : BuildPipeline
    member inline For<'Collection, 'T when 'Collection :> 'T seq> (collection: 'Collection, fn: 'T -> InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
    member inline For<'Collection, 'T when 'Collection :> 'T seq> (collection: 'Collection, fn: 'T -> InputSpec<StageContext>) : InputSpec<BuildPipeline>
    member inline For<'Collection, 'T when 'Collection :> 'T seq> (spec: InputSpec<'Collection>, fn: 'T -> StageContext) : InputSpec<BuildPipeline>
    member inline For<'Collection, 'T when 'Collection :> 'T seq> (collection: 'Collection, fn: 'T -> PipelineContext -> PipelineContext) : BuildPipeline
    member inline For<'Collection, 'T when 'Collection :> 'T seq> (collection: 'Collection, fn: 'T -> StageContext) : BuildPipeline
    member Run (spec: InputSpec<BuildPipeline>) : InputSpec<PipelineContext>
    member Run (build: BuildPipeline) : PipelineContext
    member inline Run () : unit
    member inline Yield (specs: InputSpec<StageContext> seq) : InputSpec<BuildPipeline>
    member inline Yield (spec: InputSpec<StageContext list>) : InputSpec<BuildPipeline>
    member inline Yield (spec: InputSpec<StageContext seq>) : InputSpec<BuildPipeline>
    member inline Yield (stages: StageContext seq) : BuildPipeline
    member inline Yield (condition: BuildStageIsActive) : BuildStageIsActive
    member inline Yield (spec: InputSpec<StageContext>) : InputSpec<BuildPipeline>
    member inline Yield (stage: StageContext) : BuildPipeline
    member inline Yield () : BuildPipeline
    member inline YieldFrom (specs: InputSpec<StageContext> seq) : InputSpec<BuildPipeline>
    member inline YieldFrom (stages: StageContext seq) : BuildPipeline
    member inline Zero () : BuildPipeline
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `PipelineSettingsBuilder` | Builds a pipeline from stages. A pipeline declaring nothing stays a plain `PipelineContext` and runs without a `ParseResult`; as soon as one stage is an `InputSpec` the pipeline becomes an `InputSpec` and the inputs of every stage are unioned into it. The members that change representation — `Yield`, `Delay`, `Combine`, `For`, `Run` — come in one flavour per representation; the settings inherited from `PipelineSettingsBuilder` are generic in it. ## Constructors
new (name: string) : PipelineBuilder
## Methods
member Combine (spec: InputSpec<BuildPipeline>, stage: StageContext) : InputSpec<BuildPipeline>
member Combine (spec: InputSpec<BuildPipeline>, rest: BuildPipeline) : InputSpec<BuildPipeline>
member inline Combine (build: InputSpec<BuildPipeline>, rest: InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
member inline Combine (spec: InputSpec<StageContext>, rest: InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
member Combine (spec: InputSpec<StageContext>, build: BuildPipeline) : InputSpec<BuildPipeline>
member Combine (build: BuildPipeline, rest: InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
member Combine (stage: StageContext, spec: InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
member inline Combine (stage: StageContext, build: BuildPipeline) : BuildPipeline
member inline Combine (build: BuildPipeline, rest: BuildPipeline) : BuildPipeline
member inline Delay (fn: unit -> InputSpec<StageContext>) : InputSpec<BuildPipeline>
member inline Delay (fn: unit -> InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
member inline Delay (fn: unit -> StageContext) : BuildPipeline
member inline Delay (fn: unit -> PipelineContext -> PipelineContext) : BuildPipeline
member inline Delay (fn: unit -> unit) : unit
member inline For (spec: InputSpec<BuildPipeline>, fn: unit -> InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
member For (build: BuildPipeline, fn: unit -> InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
member inline For (spec: InputSpec<BuildPipeline>, fn: unit -> InputSpec<StageContext>) : InputSpec<BuildPipeline>
member For (spec: InputSpec<BuildPipeline>, fn: unit -> StageContext) : InputSpec<BuildPipeline>
member For (build: BuildPipeline, fn: unit -> InputSpec<StageContext>) : InputSpec<BuildPipeline>
member inline For (build: BuildPipeline, fn: unit -> StageContext) : BuildPipeline
member inline For (build: BuildPipeline, fn: unit -> PipelineContext -> PipelineContext) : BuildPipeline
member inline For<'Collection, 'T when 'Collection :> 'T seq> (collection: 'Collection, fn: 'T -> InputSpec<BuildPipeline>) : InputSpec<BuildPipeline>
member inline For<'Collection, 'T when 'Collection :> 'T seq> (collection: 'Collection, fn: 'T -> InputSpec<StageContext>) : InputSpec<BuildPipeline>
member inline For<'Collection, 'T when 'Collection :> 'T seq> (spec: InputSpec<'Collection>, fn: 'T -> StageContext) : InputSpec<BuildPipeline>
member inline For<'Collection, 'T when 'Collection :> 'T seq> (collection: 'Collection, fn: 'T -> PipelineContext -> PipelineContext) : BuildPipeline
member inline For<'Collection, 'T when 'Collection :> 'T seq> (collection: 'Collection, fn: 'T -> StageContext) : BuildPipeline
member Run (build: BuildPipeline) : PipelineContext
member inline Run () : unit
member inline Yield (specs: InputSpec<StageContext> seq) : InputSpec<BuildPipeline>
A list of ready-made blocks - `[ Blocks.restore; Blocks.build ]` - rather than one block yielding many stages.
member inline Yield (spec: InputSpec<StageContext list>) : InputSpec<BuildPipeline>
member inline Yield (spec: InputSpec<StageContext seq>) : InputSpec<BuildPipeline>
member inline Yield (stages: StageContext seq) : BuildPipeline
member inline Yield (condition: BuildStageIsActive) : BuildStageIsActive
member inline Yield (spec: InputSpec<StageContext>) : InputSpec<BuildPipeline>
member inline Yield (stage: StageContext) : BuildPipeline
member inline Yield () : BuildPipeline
member inline YieldFrom (specs: InputSpec<StageContext> seq) : InputSpec<BuildPipeline>
member inline YieldFrom (stages: StageContext seq) : BuildPipeline
member inline Zero () : BuildPipeline
--- # PipelineRun
type PipelineRun =
    {
        Name: string
        Reports: ScopeReport list
        Timings: StageTiming list
    }
| | | |---|---| | Assembly | `Partas.Build` | A pipeline run by an invocation, as its scopes reported themselves. ## Fields
Name: string
Reports: ScopeReport list
The scopes recorded at pipeline level, in the order they finished, each carrying its nested scopes.
Timings: StageTiming list
Every stage the run finished, in pre-order.
--- # Producer
module Producer
| | | |---|---| | Assembly | `Partas.Build` | ## Functions and values
define<'I, 'D, 'T> (name: string) (inputs: InputSpec<'I>) (dependencies: DependencySpec<'D>) (execute: 'I -> 'D -> Operation<'T>) : Producer<'T>
Declares a producer of `'T` from its CLI inputs, its prerequisites, and the work that computes the value. Declaration allocates an identity and harvests inputs; `execute` is called when a consumer schedules the producer, and the operation it answers runs after that. | Parameter | | |---|---| | `name` | | | `inputs` | | | `dependencies` | | | `execute` | |
emptyDefine<'T> (name: string) (operation: Operation<'T>) : Producer<'T>
Defines a producer identified operation which has no dependencies.
stage<'T> (producer: Producer<'T>) : StageContext
Lists a producer at this exact point in a pipeline or parent stage.
## Extension members
member TryGetOutput<'T> (producer: Producer<'T>) : 'T voption
The value `producer` published, where the invocation still holds one of the type the handle declares. A lookup over the values already published: an absent value answers `ValueNone`, leaving the producer unrun, as does a value a retried scope discarded. | Parameter | | |---|---| | `producer` | |
--- # ProducerId
[<Struct>]
type ProducerId =
    | ProducerId of id: int64
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | A key allocated for one producer declaration and retained by every copy of its handle. ## Cases
ProducerId of id: int64
--- # ProducerLocation
type ProducerLocation =
    {
        PipelineIndex: int
        IsPostStage: bool
        Path: int list
    }
| | | |---|---| | Assembly | `Partas.Build` | A declaration-order address of a stage in one pipeline. Nested path elements are step indexes. ## Fields
PipelineIndex: int
IsPostStage: bool
Path: int list
--- # ProducerPlacement
type ProducerPlacement =
    {
        Producer: ProducerRef
        Owner: ProducerLocation voption
        Before: ProducerLocation
        IsExplicit: bool
    }
| | | |---|---| | Assembly | `Partas.Build` | A producer's validated position in one invocation. ## Fields
Producer: ProducerRef
Owner: ProducerLocation voption
None denotes the pipeline scope; otherwise the enclosing stage owns the value.
Before: ProducerLocation
The stage before which implicit work must run, or the explicitly listed stage itself.
IsExplicit: bool
--- # ProducerValues
[<Struct>]
type ProducerValues =
    | ProducerValues of Map<ProducerId,struct (Type * obj)>
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Values published during one invocation or attempt scope. A value is held alongside the type it was produced as. A published `None` or `ValueNone` reads back as `ValueSome None` or `ValueSome ValueNone`: intentional absence is a result a consumer handles. ## Cases
ProducerValues of Map<ProducerId,struct (Type * obj)>
## Functions and values
add<'T> (id: ProducerId) (value: 'T) (ProducerValues) : ProducerValues
addBoxed (id: ProducerId) (produced: Type) (value: obj) (ProducerValues) : ProducerValues
contains (id: ProducerId) (ProducerValues) : bool
tryGet<'T> (id: ProducerId) (ProducerValues) : 'T voption
--- # RunOutcome
[<Struct>]
type RunOutcome =
    | Succeeded
    | Failed
    | UsageError
    | Cancelled
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` `[]` | The category of an invocation's result; `ExitCode` carries the corresponding code. ## Cases
Succeeded
Exit code 0.
Failed
Exit code 1, or any code outside the categories below.
UsageError
Exit code 2.
Cancelled
Exit code 130.
## Functions and values
ofExitCode (exitCode: int) : RunOutcome
## Properties
member IsCancelled: bool
member IsFailed: bool
member IsSucceeded: bool
member IsUsageError: bool
--- # RunResult
type RunResult =
    {
        ExitCode: int
        Outcome: RunOutcome
        Pipelines: PipelineRun list
    }
| | | |---|---| | Assembly | `Partas.Build` | The structured result of one command invocation. `Pipelines` holds the pipelines the invocation started, in the order they ran, including one that failed or was cancelled. It is empty for an invocation that ran nothing: help, a version, `--explain`, or a usage error. ```fsharp let result = Command.invoke [ "test" ] root for timing in result.Timings do printfn "%s%s %.0fms" (String.replicate timing.Depth " ") timing.Name timing.Elapsed.TotalMilliseconds for failure in result.Failures do printfn "step %d: %s" failure.Index (FailureCause.describe failure.Cause) ``` ## Fields
ExitCode: int
Outcome: RunOutcome
Pipelines: PipelineRun list
## Functions and values
toJson (indented: bool) (result: RunResult) : string
`result` as a JSON document, one line long unless `indented`. Each pipeline carries its reports, nested as the scopes nest, and its timings in pre-order. A failure's `step` counts from zero and is `null` for a cause no step produced. A command's line is its log form, with every secret masked. A failed run, as `--json` prints it (indented here): ```json {"formatVersion": 1, "exitCode": 1, "outcome": "failed", "pipelines": [{"name": "test", "reports": [{"name": "unit", "address": "unit", "path": [0], "outcome": "failed", "error": "Exit code not acceptable.", "propagates": true, "failures": [{"step": 0, "label": "dotnet test", "cause": {"kind": "reported", "message": "Exit code not acceptable."}}], "nested": []}], "timings": [{"name": "unit", "depth": 0, "elapsedMs": 90.7, "outcome": "failed", "error": "Exit code not acceptable."}]}]} ```
## Properties
member Failures: StepFailure list
Every failure recorded by the run, absorbed failures among them, in pre-order.
member Reports: ScopeReport list
The pipeline-level reports of every pipeline run, in run order.
member Timings: StageTiming list
The stage timings of every pipeline run, in run order.
--- # ScopeAddress
[<Struct>]
type ScopeAddress =
    {
        Path: int list
        Names: string list
    }
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | Where a scope sits in the run. `Path` holds the position of each scope from the stage of the pipeline inward, ending in the position of the scope addressed; `Names` holds the names of the same scopes. Two sibling scopes sharing a name differ in the last position. ## Fields
Path: int list
Names: string list
## Functions and values
child (ordinal: int) (name: string) (address: ScopeAddress) : ScopeAddress
The address of the scope at `ordinal` of the scope `address` addresses. | Parameter | | |---|---| | `ordinal` | | | `name` | | | `address` | |
root: ScopeAddress
The address of the pipeline itself, which encloses every scope of the run.
text (address: ScopeAddress) : string
The names from the outermost scope inward, separated by '/'.
--- # ScopeReport
type ScopeReport =
    {
        Name: string
        Address: ScopeAddress
        Outcome: StageOutcome
        Propagates: bool
        Failures: StepFailure list
        Exceptions: exn list
        Nested: ScopeReport list
    }
| | | |---|---| | Assembly | `Partas.Build` | What one execution of a scope did, the evidence its steps left, and whether its failure reaches the scope containing it. `Outcome` is the scope's own result and `Propagates` the decision taken from it, held apart: a failure a `continueStageOnFailure` absorbs reports `Failed` with `Propagates = false`. `Failures` retains every cause of the reported execution, the absorbed ones among them, and `Exceptions` holds what the containing scope received. ## Fields
Name: string
Address: ScopeAddress
Where the scope sits in the run.
Outcome: StageOutcome
Propagates: bool
Whether a failure of this scope fails the scope containing it.
Failures: StepFailure list
Exceptions: exn list
The exceptions offered to the containing scope.
Nested: ScopeReport list
The reports of the scopes nested under this one, in the order they finished.
## Functions and values
continues (report: ScopeReport) : bool
Whether the scope containing carries on past it.
failed (report: ScopeReport) : bool
Whether the reported execution failed, whatever the propagation decision taken from it.
failures (report: ScopeReport) : StepFailure list
The failures of and of the scopes nested under it, in pre-order.
flatten (report: ScopeReport) : ScopeReport list
propagated (report: ScopeReport) : StepFailure list
The failures that reached the scope containing `report`, in pre-order. A scope that absorbs its own failure is a boundary: the failures below it stay with it.
--- # ScopeReports
type ScopeReports =
    {
        recorded: List<ScopeReport>
    }
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | The scopes one pipeline invocation ran, as they reported themselves. Invocation-local: a run empties the collector before its first stage. A stage of the pipeline appends as it finishes and carries the scopes nested under it in its own report, and the pipeline appends itself last where its own handlers left a cause. ## Fields
recorded: List<ScopeReport>
## Functions and values
add (report: ScopeReport) (reports: ScopeReports) : unit
Records after the scopes already recorded.
all (reports: ScopeReports) : ScopeReport list
Every scope of the run, each nested scope under the one containing it.
clear (reports: ScopeReports) : unit
Discards every recorded scope. An invocation starts here.
create () : ScopeReports
failures (reports: ScopeReports) : StepFailure list
Every failure of the run, the absorbed ones among them, in pre-order.
propagated (reports: ScopeReports) : StepFailure list
The failures that reached the pipeline, in pre-order.
stages (reports: ScopeReports) : ScopeReport list
The scopes recorded at pipeline level, in the order they finished. The pipeline's own stages, and the pipeline itself where its handlers left a cause.
--- # Stage
module Stage
| | | |---|---| | Assembly | `Partas.Build` | Stages defined from what they consume. ## Functions and values
consumes<'D> (dependencies: DependencySpec<'D>) (execute: 'D -> Operation<unit>) (stage: StageContext) : StageContext
The stage with `dependencies` added to what it requires, and `execute` added as one more step over the values its scope has published. The step runs the operation over the values the invocation has published, or fails naming the first unavailable prerequisite. Every setting already on the stage is kept, so a consumer written through the `consumes` operation of a stage builder carries that builder's `retry` and conditions. | Parameter | | |---|---| | `dependencies` | | | `execute` | | | `stage` | |
consuming<'D> (name: string) (dependencies: DependencySpec<'D>) (execute: 'D -> Operation<unit>) : StageContext
A stage whose work consumes producer results and returns unit.
consumingWith<'I, 'D> (name: string) (inputs: InputSpec<'I>) (dependencies: DependencySpec<'D>) (execute: 'I -> 'D -> Operation<unit>) : InputSpec<StageContext>
A stage that consumes CLI inputs of its own alongside producer results.
--- # StageAddress
type StageAddress =
    {
        Location: ProducerLocation
        Ordinal: int
        Ancestors: (StageContext * ProducerLocation) list
    }
| | | |---|---| | Assembly | `Partas.Build` | Where one stage sits in a pipeline. ## Fields
Location: ProducerLocation
Ordinal: int
Declaration order, a stage ahead of the stages nested in it.
Ancestors: (StageContext * ProducerLocation) list
The stages enclosing this one, innermost first, with their addresses.
## Functions and values
rebuildPipeline (rebuild: StageAddress -> StageContext -> StageContext list) (pipelineIndex: int) (pipeline: PipelineContext) : PipelineContext
The pipeline with each of its stages rebuilt from that stage's address. `rebuild` receives a stage whose own sub-stages have already been rebuilt, and answers the stages standing in its place. Addresses are allocated in declaration order, a stage ahead of the stages nested in it. The one traversal that gives a stage a [`ProducerLocation`](/Partas.Build/reference/partas-build/partas-build/producerlocation/): validation and scheduling address stages through it. | Parameter | | |---|---| | `rebuild` | | | `pipelineIndex` | | | `pipeline` | |
--- # StageBuilder
module StageBuilder
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | ## Declared inside | | | |---|---| | [`StageBuilder`](/Partas.Build/reference/partas-build/partas-build/stagebuilder/stagebuilder/) | | | [`s`](/Partas.Build/reference/partas-build/partas-build/stagebuilder/s/) | | | [`second`](/Partas.Build/reference/partas-build/partas-build/stagebuilder/second/) | | ## Functions and values
stage (name: string) : StageBuilder
Builds a named stage: settings, conditions, steps and nested stages, run in the order written. The result is a `StageContext`, or an `InputSpec` when a nested stage declares CLI inputs. Either is yielded into a `pipeline`, a `command` or another `stage`. ```fsharp stage "build" { workingDir "src" whenBranch "main" run "dotnet restore" run (cmd $"dotnet build -c {configuration}") stage "docs" { run "dotnet run --project docs" } } ```
--- # s
[<Measure>] type s
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | --- # second
[<Measure>] type second
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | --- # StageBuilder
[<Class>]
type StageBuilder =
    new (name: string) : StageBuilder
    member inline Combine (condition: BuildStageIsActive, spec: InputSpec<BuildStage>) : InputSpec<BuildStage>
    member inline Combine (builder: BuildStep, spec: InputSpec<(StageContext -> StageContext)>) : InputSpec<BuildStage>
    member Combine (stage: StageContext, spec: InputSpec<BuildStage>) : InputSpec<BuildStage>
    member inline Combine (spec: InputSpec<StageContext>, rest: InputSpec<(StageContext -> StageContext)>) : InputSpec<BuildStage>
    member Combine (spec: InputSpec<StageContext>, build: BuildStage) : InputSpec<BuildStage>
    member Combine (spec: InputSpec<BuildStage>, rest: BuildStage) : InputSpec<BuildStage>
    member inline Combine<'a> (spec: InputSpec<(StageContext -> 'a)>, rest: InputSpec<('a -> StageContext)>) : InputSpec<BuildStage>
    member Combine (build: BuildStage, spec: InputSpec<BuildStage>) : InputSpec<BuildStage>
    member inline Combine (build1: BuildStage, build2: BuildStage) : StageContext -> StageContext
    member inline Combine (condition: BuildStageIsActive, build: BuildStage) : BuildStage
    member inline Combine (stage: StageContext, build: BuildStage) : BuildStage
    member inline Combine (builder: BuildStep, build: StageContext -> StageContext) : BuildStage
    member inline Delay (fn: unit -> InputSpec<StageContext>) : InputSpec<(StageContext -> StageContext)>
    member inline Delay (fn: unit -> InputSpec<BuildStage>) : InputSpec<BuildStage>
    member inline Delay (fn: unit -> StageContext -> bool) : StageContext -> StageContext
    member inline Delay (fn: unit -> StageContext -> StepIndex -> Async<Result<unit,string>>) : StageContext -> StageContext
    member inline Delay (fn: unit -> StageContext) : StageContext -> StageContext
    member inline Delay (fn: unit -> StageContext -> StageContext) : BuildStage
    member inline For (spec: InputSpec<BuildStage>, fn: unit -> InputSpec<StageContext>) : InputSpec<BuildStage>
    member inline For (spec: InputSpec<(StageContext -> StageContext)>, fn: unit -> InputSpec<BuildStage>) : InputSpec<BuildStage>
    member inline For (spec: InputSpec<BuildStage>, fn: unit -> StageContext -> bool) : InputSpec<BuildStage>
    member inline For (spec: InputSpec<(StageContext -> StageContext)>, fn: unit -> StageContext -> StepIndex -> Async<Result<unit,string>>) : InputSpec<BuildStage>
    member For (spec: InputSpec<BuildStage>, fn: unit -> StageContext) : InputSpec<BuildStage>
    member For (spec: InputSpec<BuildStage>, fn: unit -> StageContext -> StageContext) : InputSpec<BuildStage>
    member For (build: BuildStage, fn: unit -> InputSpec<StageContext>) : InputSpec<BuildStage>
    member For (build: BuildStage, fn: unit -> InputSpec<BuildStage>) : InputSpec<BuildStage>
    member inline For<'T> (items: 'T seq, fn: 'T -> StageContext -> StepIndex -> Async<Result<unit,string>>) : BuildStage
    member inline For<'T> (items: 'T seq, fn: 'T -> InputSpec<BuildStage>) : InputSpec<BuildStage>
    member inline For<'T> (items: 'T seq, fn: 'T -> InputSpec<StageContext>) : InputSpec<BuildStage>
    member inline For<'T> (items: 'T seq, fn: 'T -> StageContext -> StageContext) : BuildStage
    member inline For<'T> (items: 'T seq, fn: 'T -> StageContext) : BuildStage
    member inline For (build: BuildStage, fn: unit -> StageContext -> bool) : BuildStage
    member inline For (build: BuildStage, fn: unit -> StageContext -> StepIndex -> Async<Result<unit,string>>) : BuildStage
    member inline For (build: BuildStage, fn: unit -> StageContext) : BuildStage
    member inline For (build: StageContext -> StageContext, fn: unit -> StageContext -> StageContext) : BuildStage
    member Run (spec: InputSpec<BuildStage>) : InputSpec<StageContext>
    member Run (build: BuildStage) : StageContext
    member inline Yield (spec: InputSpec<BuildStep>) : InputSpec<(StageContext -> StageContext)>
    member inline Yield (specs: InputSpec<StageContext> seq) : InputSpec<(StageContext -> StageContext)>
    member inline Yield<'a when 'a :> StageContext seq> (spec: InputSpec<'a>) : InputSpec<(StageContext -> StageContext)>
    member inline Yield (spec: InputSpec<StageContext>) : InputSpec<(StageContext -> StageContext)>
    member inline Yield (stages: StageContext seq) : StageContext -> StageContext
    member inline Yield (condition: BuildStageIsActive) : BuildStageIsActive
    member inline Yield (builder: BuildStep) : BuildStep
    member inline Yield (stage: StageContext) : StageContext -> StageContext
    member inline Yield () : BuildStage
    member inline YieldFrom (steps: BuildStep seq) : BuildStage
    member inline YieldFrom (specs: InputSpec<StageContext> seq) : InputSpec<BuildStage>
    member inline YieldFrom (stages: StageContext seq) : BuildStage
    member inline Zero () : BuildStage
    member inline run (spec: InputSpec<BuildStage>, buildStep: StageContext -> StageContext -> StepIndex -> Async<Result<unit,string>>) : InputSpec<BuildStage>
    member run (build: BuildStage, buildStep: StageContext -> StageContext -> StepIndex -> Async<Result<unit,string>>) : BuildStage
| | | |---|---| | Assembly | `Partas.Build` | | Inherits | `StageSettingsBuilder` | | Attributes | `[]` | ## Constructors
new (name: string) : StageBuilder
## Methods
member inline Combine (condition: BuildStageIsActive, spec: InputSpec<BuildStage>) : InputSpec<BuildStage>
member inline Combine (builder: BuildStep, spec: InputSpec<(StageContext -> StageContext)>) : InputSpec<BuildStage>
member Combine (stage: StageContext, spec: InputSpec<BuildStage>) : InputSpec<BuildStage>
member inline Combine (spec: InputSpec<StageContext>, rest: InputSpec<(StageContext -> StageContext)>) : InputSpec<BuildStage>
member Combine (spec: InputSpec<StageContext>, build: BuildStage) : InputSpec<BuildStage>
member Combine (spec: InputSpec<BuildStage>, rest: BuildStage) : InputSpec<BuildStage>
member inline Combine<'a> (spec: InputSpec<(StageContext -> 'a)>, rest: InputSpec<('a -> StageContext)>) : InputSpec<BuildStage>
member Combine (build: BuildStage, spec: InputSpec<BuildStage>) : InputSpec<BuildStage>
member inline Combine (build1: BuildStage, build2: BuildStage) : StageContext -> StageContext
member inline Combine (condition: BuildStageIsActive, build: BuildStage) : BuildStage
member inline Combine (stage: StageContext, build: BuildStage) : BuildStage
member inline Combine (builder: BuildStep, build: StageContext -> StageContext) : BuildStage
member inline Delay (fn: unit -> InputSpec<StageContext>) : InputSpec<(StageContext -> StageContext)>
member inline Delay (fn: unit -> InputSpec<BuildStage>) : InputSpec<BuildStage>
member inline Delay (fn: unit -> StageContext -> bool) : StageContext -> StageContext
member inline Delay (fn: unit -> StageContext -> StepIndex -> Async<Result<unit,string>>) : StageContext -> StageContext
member inline Delay (fn: unit -> StageContext) : StageContext -> StageContext
member inline Delay (fn: unit -> StageContext -> StageContext) : BuildStage
member inline For (spec: InputSpec<BuildStage>, fn: unit -> InputSpec<StageContext>) : InputSpec<BuildStage>
member inline For (spec: InputSpec<(StageContext -> StageContext)>, fn: unit -> InputSpec<BuildStage>) : InputSpec<BuildStage>
member inline For (spec: InputSpec<BuildStage>, fn: unit -> StageContext -> bool) : InputSpec<BuildStage>
member inline For (spec: InputSpec<(StageContext -> StageContext)>, fn: unit -> StageContext -> StepIndex -> Async<Result<unit,string>>) : InputSpec<BuildStage>
member For (spec: InputSpec<BuildStage>, fn: unit -> StageContext) : InputSpec<BuildStage>
member For (spec: InputSpec<BuildStage>, fn: unit -> StageContext -> StageContext) : InputSpec<BuildStage>
member For (build: BuildStage, fn: unit -> InputSpec<StageContext>) : InputSpec<BuildStage>
member For (build: BuildStage, fn: unit -> InputSpec<BuildStage>) : InputSpec<BuildStage>
member inline For<'T> (items: 'T seq, fn: 'T -> StageContext -> StepIndex -> Async<Result<unit,string>>) : BuildStage
member inline For<'T> (items: 'T seq, fn: 'T -> InputSpec<BuildStage>) : InputSpec<BuildStage>
member inline For<'T> (items: 'T seq, fn: 'T -> InputSpec<StageContext>) : InputSpec<BuildStage>
member inline For<'T> (items: 'T seq, fn: 'T -> StageContext -> StageContext) : BuildStage
member inline For<'T> (items: 'T seq, fn: 'T -> StageContext) : BuildStage
member inline For (build: BuildStage, fn: unit -> StageContext -> bool) : BuildStage
member inline For (build: BuildStage, fn: unit -> StageContext -> StepIndex -> Async<Result<unit,string>>) : BuildStage
member inline For (build: BuildStage, fn: unit -> StageContext) : BuildStage
member inline For (build: StageContext -> StageContext, fn: unit -> StageContext -> StageContext) : BuildStage
member Run (spec: InputSpec<BuildStage>) : InputSpec<StageContext>
member Run (build: BuildStage) : StageContext
member inline Yield (spec: InputSpec<BuildStep>) : InputSpec<(StageContext -> StageContext)>
member inline Yield (specs: InputSpec<StageContext> seq) : InputSpec<(StageContext -> StageContext)>
A list of ready-made blocks - `[ Blocks.restore; Blocks.build ]` - rather than one block yielding many stages.
member inline Yield<'a when 'a :> StageContext seq> (spec: InputSpec<'a>) : InputSpec<(StageContext -> StageContext)>
member inline Yield (spec: InputSpec<StageContext>) : InputSpec<(StageContext -> StageContext)>
member inline Yield (stages: StageContext seq) : StageContext -> StageContext
member inline Yield (condition: BuildStageIsActive) : BuildStageIsActive
member inline Yield (builder: BuildStep) : BuildStep
member inline Yield (stage: StageContext) : StageContext -> StageContext
member inline Yield () : BuildStage
member inline YieldFrom (steps: BuildStep seq) : BuildStage
member inline YieldFrom (specs: InputSpec<StageContext> seq) : InputSpec<BuildStage>
member inline YieldFrom (stages: StageContext seq) : BuildStage
member inline Zero () : BuildStage
member inline run (spec: InputSpec<BuildStage>, buildStep: StageContext -> StageContext -> StepIndex -> Async<Result<unit,string>>) : InputSpec<BuildStage>
The `InputSpec` mirror of the operation of the same name.
member run (build: BuildStage, buildStep: StageContext -> StageContext -> StepIndex -> Async<Result<unit,string>>) : BuildStage
Adds a step built from a context-dependent function. The function receives the current stage context and returns a step function that operates on that context. Unlike its neighbours this overload stays a mirrored pair over the concrete representations. It is the only `run` taking no optional argument, which is what resolves `run (fun ctx -> failwith "...")` - a lambda whose return type the call site leaves open. Generic in the state it ties with the flexible-signature overload and such a call site stops compiling.
--- # StageContext
module StageContext
| | | |---|---| | Assembly | `Partas.Build` | ## Functions and values
addBuildStep (step: BuildStep) (stage: StageContext) : StageContext
addBuildSteps (steps: BuildStep seq) (stage: StageContext) : StageContext
addEnvVars (kvs: (string * string) seq) (stage: StageContext) : StageContext
addFailureHandler (handler: FailureHandler) (stage: StageContext) : StageContext
Registers `handler` to run when the stage fails, after the handlers already registered on it. | Parameter | | |---|---| | `handler` | | | `stage` | |
addLabelledStepFn (label: string) (step: BuildStep) (stage: StageContext) : StageContext
addOperation (label: string voption) (operation: RuntimeContext -> Async<StepOutcome>) (stage: StageContext) : StageContext
addPredicate (condition: BuildStageIsActive) (stage: StageContext) : StageContext
Conjoins a condition onto a stage. --explain reports a skip caused by it without a reason.
addPredicateBecause (reason: string voption) (condition: BuildStageIsActive) (stage: StageContext) : StageContext
Conjoins a condition onto a stage and records against it. The single writer of both IsActive and Conditions, so the two stay in step.
addStep (step: Step) (stage: StageContext) : StageContext
addStepFn: BuildStep -> StageContext -> StageContext
addSteps (steps: Step seq) (stage: StageContext) : StageContext
addSubStage (subStage: StageContext) (stage: StageContext) : StageContext
addSubStages (subStages: StageContext seq) (stage: StageContext) : StageContext
buildStageIsActive (build: BuildStage) (conditionFn: BuildStageIsActive) : BuildStage
Conjoins a condition onto the stage a BuildStage produces. Conditions accumulate rather than replace, so a stage declaring several is active only when all hold. --explain reports a skip caused by this condition without a reason.
buildStageIsActiveBecause (reason: string voption) (build: BuildStage) (conditionFn: BuildStageIsActive) (ctx: StageContext) : StageContext
Conjoins a condition onto the stage a BuildStage produces, recording against it. Conditions accumulate rather than replace, so a stage declaring several is active only when all hold.
declaredInputs (stage: StageContext) : ActionInput list
The inputs stage and the stages nested under it declare, deduplicated. This is the one traversal every builder harvests through, so a producer's options reach the command whichever of the stage, pipeline or command builders a consumer was written in.
getAllEnvVars (ctx: StageContext) : Map<string,string>
getEnvVar (ctx: StageContext) (key: string) : string
getNamePath (ctx: StageContext) : string
The `/`-separated names of the stages enclosing the stage, outermost first, ending in its own.
getOutput (ctx: StageContext) : StageOutput voption
Where the stage's step output goes, from the nearest declaration walking upward. `ValueNone` is `StageOutput.Console`.
getStageLevel (ctx: StageContext) : int
getTimeoutForStage (ctx: StageContext) : int
getTimeoutForStep (ctx: StageContext) : int
getVerbosity (ctx: StageContext) : Verbosity
The verbosity in effect for the stage.
getWorkingDir (ctx: StageContext) : string voption
publishedValues (stage: StageContext) : ProducerValues
The producer values available to `stage`. The values of the invocation running the pipeline that contains the stage, wherever the stage sits under it. Empty for a stage run outside a pipeline, and ahead of the first producer of a run. | Parameter | | |---|---| | `stage` | |
runHttpHealthCheck (ctx: StageContext) (url: string) : Async<Result<unit,string>>
runHttpHealthCheckCancelable (ctx: StageContext) (cancellationToken: CancellationToken) (url: string) : Async<Result<unit,string>>
runHttpHealthCheckCancelableWithConfigRequest (ctx: StageContext) (cancellationToken: CancellationToken) (configRequest: HttpRequestMessage -> unit) (url: string) : Async<Result<unit,string>>
runHttpHealthCheckWithConfigRequest (ctx: StageContext) (configRequest: HttpRequestMessage -> unit) (url: string) : Async<Result<unit,string>>
setAcceptableExitCodes (codes: int seq) (stage: StageContext) : StageContext
setContinueOnStepFailure (continueOnStepFailure: bool) (stage: StageContext) : StageContext
setContinueStageOnFailure (continueStageOnFailure: bool) (stage: StageContext) : StageContext
setContinueStepsOnFailure (continueStepsOnFailure: bool) (stage: StageContext) : StageContext
setFailIfIgnored (failIfIgnored: bool) (stage: StageContext) : StageContext
setFailIfNoActiveSubStage (failIfNoActiveSubStage: bool) (stage: StageContext) : StageContext
setNoPrefixForStep (noPrefixForStep: bool) (stage: StageContext) : StageContext
setNoStdRedirectForStep (noStdRedirectForStep: bool) (stage: StageContext) : StageContext
setOutput (output: StageOutput voption) (stage: StageContext) : StageContext
setParallelism (parallelism: int) (stage: StageContext) : StageContext
setShuffleExecuteSequence (shuffleExecuteSequence: bool) (stage: StageContext) : StageContext
setSteps (steps: Step list) (stage: StageContext) : StageContext
setTimeoutForStepMilliseconds (timeout: int voption) (stage: StageContext) : StageContext
setTimeoutForStepSeconds (timeout: int voption) (stage: StageContext) : StageContext
setTimeoutForStepTimeSpan (timeout: TimeSpan voption) (stage: StageContext) : StageContext
setTimeoutMilliseconds (timeout: int voption) (stage: StageContext) : StageContext
setTimeoutSeconds (timeout: int voption) (stage: StageContext) : StageContext
setTimeoutTimeSpan (timeout: TimeSpan voption) (stage: StageContext) : StageContext
setWorkingDir (workingDir: string voption) (stage: StageContext) : StageContext
softCancelStage<'a> (StageContext) : 'a
Call when you wish to tear down the execution pipeline
softCancelStep<'a> (StageContext) : 'a
Call when you wish to tear down the execution pipeline
toggleParallel (toggle: bool) (stage: StageContext) : StageContext
tryGetEnvVar (ctx: StageContext) (key: string) : string voption
writeLine (ctx: StageContext) (stream: StdStream) (line: string) : unit
Writes one line of step output to the stage's sink: the console, a capture, a redirect, or nowhere for a silenced stage. The routed counterpart of `printfn` inside a step. A bare `printfn` reaches the console whatever the stage's output setting is.
--- # StageTiming
[<Struct>]
type StageTiming =
    {
        Name: string
        Depth: int
        Elapsed: TimeSpan
        Outcome: StageOutcome
    }
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | The wall time of one stage of a run, with how the stage ended. `Elapsed` covers the stage's own steps and every stage nested under them. ## Fields
Name: string
Depth: int
The number of stages enclosing this one; 0 for a stage of the pipeline itself.
Elapsed: TimeSpan
Outcome: StageOutcome
--- # StageTimings
type StageTimings =
    {
        entries: ConcurrentBag<struct (int64 * int64 * StageTiming)>
        started: int64
    }
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | The stages a pipeline run has finished, as the tree the stages nest into. Stages append concurrently under `parallel'`. `Start` supplies the ordinal that orders a stage among its siblings and that its own sub-stages record as their parent; a stage of the pipeline records `0L`. ## Fields
entries: ConcurrentBag<struct (int64 * int64 * StageTiming)>
started: int64
## Functions and values
add (parent: int64) (order: int64) (stageTiming: StageTiming) (stageTimings: StageTimings) : unit
clear (stageTimings: StageTimings) : unit
Discards every recorded stage. A second run of the same pipeline value reports itself alone.
create () : StageTimings
ordered (stageTimings: StageTimings) : StageTiming list
Every recorded stage in pre-order, each sub-stage under the stage containing it. Siblings read in start order, which `parallel'` makes nondeterministic.
start (stageTimings: StageTimings) : int64
The ordinal of the stage starting now.
--- # StepFailure
[<Struct>]
type StepFailure =
    {
        Index: int
        Label: string voption
        Cause: FailureCause
    }
| | | |---|---| | Assembly | `Partas.Build` | | Attributes | `[]` | A failure one step produced, with the step it came from. ## Fields
Index: int
The step's position among the declared steps of its scope, counting from zero.
Label: string voption
The label the step was declared with.
Cause: FailureCause
## Functions and values
[<Literal>] NoStep: int = -1
The Index of a cause the scope left itself, which no step of it produced.
--- # Summary
module Summary
| | | |---|---| | Assembly | `Partas.Build` | What a finished run spent, stage by stage. Every stage of the run in pre-order, each sub-stage under the stage containing it, with its wall time and how it ended. One row per stage at a usual console width: text too wide for its column is elided. ## Functions and values
render (timings: StageTiming list) : string
The table of `timings`, as text. Text only, without colour: rendering writes nothing anywhere. Columns are sized to the ambient console's width.
renderMarkdown (pipelineName: string) (timings: StageTiming list) : string
The complete stage tree as Markdown, independent of console width and verbosity.
[<Literal>] title: string = "Stage timings"
The heading printed above the table.