Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Fast inner loop while working on the library only: `dotnet build src/Partas.Buil

## Current state (verify before assuming)

- Phases 0-7 of `notes/PLAN.md` are done: `dotnet build src/Partas.Build` is clean and `dotnet run --project Build.fsproj -- test` is green (506 Expecto tests across four suites — 365 in `tests/Partas.Build.Tests`, one file per layer, plus 65 in `tests/Partas.Build.ExternalAnnotations.Tests`, 73 in `tests/Partas.ExternalAnnotations.Tests` (2 more ignored) and 3 in `tests/Partas.Build.Cmd.NetStandard.Tests`; the `test` stage also runs `tests/Partas.Build.CompilerProbe` in both Debug and Release, 11 tests each). The `Build/` CLI is written against the library: a breaking change breaks it first.
- Phases 0-7 of `notes/PLAN.md` are done: `dotnet build src/Partas.Build` is clean and `dotnet run --project Build.fsproj -- test` is green (575 Expecto tests across four suites — 431 in `tests/Partas.Build.Tests` (1 more ignored), one file per layer, plus 65 in `tests/Partas.Build.ExternalAnnotations.Tests` (6 more ignored), 73 in `tests/Partas.ExternalAnnotations.Tests` (2 more ignored) and 6 in `tests/Partas.Build.Cmd.NetStandard.Tests`; the `test` stage also runs `tests/Partas.Build.CompilerProbe` in both Debug and Release, 11 tests each). The `Build/` CLI is written against the library: a breaking change breaks it first.
- The DSL exists end to end: `inputs` (`Builders/Inputs.fs`), `stage` (`Builders/Stage.fs`), `pipeline` (`Builders/Pipeline.fs`), `command`/`rootCommand` (`Builders/Command.fs`). A stage that declares an input turns its pipeline into an `InputSpec<PipelineContext>`, and the command registers whatever those specs declare. Conditions are in `Builders/Conditions.fs` — `whenAll`/`whenAny`/`whenNot`/`whenEnv`/`whenStage` plus the `when'`/`whenEnvVar`/`whenBranch`/`when{Windows,Linux,OSX}` operations on `StageBuilder`.
- A command carries `PipelineDefaults: BuildPipeline` and takes the pipeline-level operations itself (`workingDir`, `envVars`, the three timeouts, `acceptExitCodes`, the output operations, `noPrefixForStep`/`noStdRedirectForStep`, `runBeforeEachStage`/`runAfterEachStage`, `post`, `verbosity`/`verbose`/`quiet`), each one built through `CommandBuilderBase.MapPipelineDefault`. They are **defaults, not overrides**: `PipelineContext.applyDefaults` copies a setting across only where the pipeline left it at the value `PipelineContext.create` gave it, so a pipeline that sets the same thing wins. See *Command defaults* below.
- `run`/`runSensitive` start real processes through `CmdRunner` (`Process.fs`). A `Cmd`, defined in `src/Partas.Build.Cmd/Program.fs`, keeps the executable and its arguments apart all the way to `ProcessStartInfo.ArgumentList`, so the platform does the escaping. Interpolate through the `cmd` helper — `run (cmd $"dotnet build {project}")`. `run $"..."` binds to the `string` overload and flattens the holes; `runSensitive $"..."` takes the `FormattableString` directly and masks every hole as `***`. A function returning a command line is `runLine (fun ctx -> "...")`; the `run` overloads over `StageContext -> string` (and its `Async`/`Task`/`option` forms) are `[<Obsolete>]`, since a lambda returning a message would start a process. `runLine` is a separate name, so the `run` overload set — and the `FS0041` balance below — is unchanged. There is no `Fake.Core.Process` dependency; `notes/PLAN.md`'s *The command runner* records why.
Expand Down Expand Up @@ -94,7 +94,16 @@ writes the result as `Failed` and then propagates to System.CommandLine's handle
`Input.sensitive` marks an option or argument as carrying a secret, recorded against the `Symbol` in a
`ConditionalWeakTable` in `Inputs.fs` (System.CommandLine has no property bag); `--schema` writes a marked
symbol's present default as `"***"` alongside `"sensitive": true`. `Baked.NuGet.apiKey` is marked, since its
default is read from `NUGET_API_KEY`. Text `--help` still prints the default.
default is read from `NUGET_API_KEY`. Text `--help` still prints the default. `ActionInput.GetValue` registers
every value it reads from a marked symbol with `Cmd.Redaction` (process-wide, in `Partas.Build.Cmd`), and
`Cmd.toLogString` and `--explain`'s labels mask each registered value, as a whole argument or inside one, so a
secret interpolated through `cmd`, the string `run` or `Cmd.ofList` prints as `***` and still reaches the process.

A stage body is built eagerly (`Delay` runs it at once), conditions included or not. Each `StageBuilder.Delay`
runs under `Construction.inStage`, which records the stage's name on the `Data` of any exception the body
raises and rethrows it unchanged; `Builders/Command.fs`'s `reportBuildFailure` turns an exception out of
reading the pipelines into a failed invocation naming the command and those stages, through the annotation
and `--json`/`--report` result paths, instead of System.CommandLine's unhandled-exception handler.

`Failures.fs` holds what a scope reports about itself, apart from what it prints. A `ScopeReport` carries three
fields: `Outcome` (the scope's own `StageOutcome`), `Propagates` (whether that failure fails the scope
Expand Down
8 changes: 7 additions & 1 deletion docs/content/Build/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,12 @@ accepts is accepted, and the stage runs for real, side effects included.
`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.

A condition decides whether a stage runs, not whether it is built: a stage body, both branches of an `if`
included, is built before any condition is evaluated. `key.Value` under `when' key.IsSome` throws while the
stage is built when `key` is `None`; `whenSome key (fun key -> ...)` builds the stage only for `Some`. An
exception raised while a command builds its stages fails the invocation (exit `1`) with a message naming the
command and the stages being built.

## `Input` combinators

Declaring functions:
Expand Down Expand Up @@ -429,7 +435,7 @@ Shaping combinators, all `ActionInput<'T> -> ActionInput<'T>` and all pipeable:
| `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.sensitive` | Marks the symbol secret: each value read from it prints as `***` in every logged or explained command line, and `--schema` reports it `"sensitive": true` with a present default written 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 |
Expand Down
3 changes: 2 additions & 1 deletion docs/content/Build/execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Post stages run after the main stages, including after failure.
- `continueStageOnFailure`: let the pipeline continue.
- `continueOnStepFailure`: apply both.
- `acceptExitCodes`: accept additional process exit codes.
- `failIfIgnored`: treat a skipped stage as failure.
- `failIfIgnored`: treat a skipped stage as failure. The stage body is still built when the stage is skipped;
bind a value that exists only when the stage runs with `whenSome`.

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).
3 changes: 2 additions & 1 deletion docs/content/Build/inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ If one value depends on another, derive it after binding. Keep runtime work insi
- `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.
- `Input.sensitive`: mask the value as `***` in every printed command line (the run log, `--explain` and its
JSON) and in `--schema`. The process still receives it unmasked.

See [Capabilities](CAPABILITIES.md#input-combinators) for the complete list and [Baked](baked.md) for ready-made inputs.
37 changes: 33 additions & 4 deletions src/Partas.Build.Cmd/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,35 @@ module Cmd =
[<Literal>]
let private mask = "***"

/// <summary>Values masked wherever a <see cref="T:Partas.Build.Cmd"/> is printed, in any argument.</summary>
/// <remarks>
/// The set is process-wide, and a registered value stays masked for the lifetime of the process.
/// <c>Input.sensitive</c> registers every value read from a marked input. A registered value prints as
/// <c>***</c> as a whole argument and inside a longer one (<c>--api-key=***</c>).
/// </remarks>
module Redaction =
let private values = Collections.Generic.HashSet<string>(StringComparer.Ordinal)
// Longest first, so a value containing another registered value is masked whole.
let mutable private ordered: string array = [||]

/// <summary>Adds a value to the masked set. A null, empty or whitespace value is ignored.</summary>
let register (value: string) =
if not (String.IsNullOrWhiteSpace value) then
lock values (fun () ->
if values.Add value then
ordered <- values |> Seq.sortByDescending (fun value -> value.Length) |> Array.ofSeq)

/// <summary>Replaces every occurrence of a registered value in the text with <c>***</c>.</summary>
let redact (text: string) =
let ordered = ordered

if String.IsNullOrEmpty text || ordered.Length = 0 then text
else
let mutable redacted = text
for value in ordered do
if redacted.Contains value then redacted <- redacted.Replace(value, mask)
redacted

/// <summary>Accumulates tokens across literal fragments and interpolated values.</summary>
/// <remarks>
/// A token ends at unquoted whitespace and nowhere else, so a value glued to literal text
Expand Down Expand Up @@ -220,17 +249,17 @@ module Cmd =

acc.ToCmd()

/// How the command is printed: secrets masked, and anything containing whitespace quoted so the
/// printed form can be pasted into a shell.
/// How the command is printed: secrets and every value registered with <c>Redaction</c> masked, and anything
/// containing whitespace quoted so the printed form can be pasted into a shell.
let toLogString (cmd: Cmd) =
let quoteIfNeeded (value: string) =
if String.IsNullOrEmpty value then "\"\""
elif value |> Seq.exists Char.IsWhiteSpace then "\"" + value + "\""
else value

cmd.Arguments
|> List.mapi (fun i arg -> if cmd.Secrets.Contains i then mask else quoteIfNeeded arg)
|> List.append [ quoteIfNeeded cmd.Executable ]
|> List.mapi (fun i arg -> if cmd.Secrets.Contains i then mask else quoteIfNeeded (Redaction.redact arg))
|> List.append [ quoteIfNeeded (Redaction.redact cmd.Executable) ]
|> String.concat " "


Expand Down
52 changes: 50 additions & 2 deletions src/Partas.Build/Builders/Command.fs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,44 @@ let private reportResult (output: TextWriter) (parseResult: ParseResult) (exitCo

exitCode

/// <summary>Reports an exception raised while the command built its pipelines as a failed invocation.</summary>
/// <remarks>
/// The message names the command, the stages being built and the input being read where they are known, and
/// carries the original exception's type and message. It is printed as an error annotation, the way a failed
/// stage's is. The run result holds one pipeline named after the command, with no timings and one report
/// addressed by the stages being built, an empty <c>Path</c>, and the exception as its <c>Raised</c> cause.
/// Under <c>--explain</c> no run result is written.
/// </remarks>
let private reportBuildFailure (output: TextWriter) (parseResult: ParseResult) (state: InvocationState) (explaining: bool) (commandName: string) (error: exn) =
let stages = Construction.stagePath error
let address = ScopeAddress.text { Path = []; Names = stages }
let site = [
if not stages.IsEmpty then $"stage (%s{address})"
match Construction.inputName error with
| ValueSome input -> $"input (%s{input})"
| ValueNone -> ()
]
let location = if site.IsEmpty then "" else " in " + String.concat ", " site
let cause = FailureCause.Raised error
let message = $"Command (%s{commandName}) failed while building its pipelines%s{location}. %s{FailureCause.describe cause}"

{ Annotation.error message with Title = ValueSome ("[COMMAND] " + commandName) }
|> WorkflowCommands.writeAnnotation (PipelineContext.ambientEnvironment ())

if explaining then ExitCode.Failure
else
let report = {
Name = List.tryLast stages |> Option.defaultValue commandName
Address = { Path = []; Names = stages }
Outcome = StageOutcome.Failed error.Message
Propagates = true
Failures = [ { Index = StepFailure.NoStep; Label = ValueNone; Cause = cause } ]
Exceptions = [ error ]
Nested = []
}
state.Record { Name = commandName; Reports = [ report ]; Timings = [] }
reportResult output parseResult ExitCode.Failure state.Pipelines

/// <summary>
/// Reads each pipeline out of the parse result and runs it, in declaration order. Producer work is placed into
/// the pipelines that run, after the whole invocation validates and never on the way to <c>--explain</c>.
Expand All @@ -137,7 +175,8 @@ let private reportResult (output: TextWriter) (parseResult: ParseResult) (exitCo
/// A pipeline failure or cancellation maps to its exit code; the runner has already printed it. Any other
/// exception writes the run result as a failure, then propagates to System.CommandLine's exception handler.
/// Under <c>--explain</c>, a failed dependency validation prints its message to the error writer and exits
/// <c>UsageError</c> without a run result.
/// <c>UsageError</c> without a run result. An exception raised while reading the pipelines (building their
/// stages) fails the invocation through <c>reportBuildFailure</c>, under <c>--explain</c> too.
/// </remarks>
let private invoke (command: Command) (spec: CommandSpec) (parseResult: ParseResult) =
// Parse results invoked straight through System.CommandLine get a state of their own, so that `--json` and
Expand All @@ -150,7 +189,16 @@ let private invoke (command: Command) (spec: CommandSpec) (parseResult: ParseRes
let configuration = parseResult.InvocationConfiguration
let asJson = MachineOutput.isSet MachineOutput.json parseResult
let explaining = MachineOutput.isSet Explain.option parseResult
let pipelines = spec.Pipelines |> List.map (fun pipeline -> pipeline.Read parseResult)
match
try Ok(spec.Pipelines |> List.map (fun pipeline -> pipeline.Read parseResult))
with error -> Error error
with
| Error error ->
let name =
if not (String.IsNullOrEmpty spec.Name) then spec.Name
else spec.DisplayName |> ValueOption.defaultValue command.Name
reportBuildFailure configuration.Output parseResult state explaining name error
| Ok pipelines ->
match DependencyPlan.validate pipelines with
| Error message ->
configuration.Error.WriteLine message
Expand Down
8 changes: 5 additions & 3 deletions src/Partas.Build/Builders/Conditions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ type StageBuilder with
/// <remarks>
/// <include file="../xmldoc/conditions.xml" path="/conditions/conjoin/*"/>
/// The value is typically a boolean bound by an enclosing <c>input</c> CE.
/// <include file="../xmldoc/conditions.xml" path="/conditions/builtRegardless/*"/>
/// <include file="../xmldoc/conditions.xml" path="/conditions/ceRestriction/*"/>
/// </remarks>
[<CustomOperation("when'")>]
Expand All @@ -318,6 +319,7 @@ type StageBuilder with
/// <remarks>
/// <include file="../xmldoc/conditions.xml" path="/conditions/conjoin/*"/>
/// <c>when' (not quick) "--quick is set"</c> renders a skipped stage as <c>(skipped: --quick is set)</c>.
/// <include file="../xmldoc/conditions.xml" path="/conditions/builtRegardless/*"/>
/// <include file="../xmldoc/conditions.xml" path="/conditions/ceRestriction/*"/>
/// </remarks>
/// <param name="build" />
Expand Down Expand Up @@ -546,9 +548,9 @@ let inline whenStage name = WhenStageBuilder name
module ValueConditions =
/// <summary>Yields the stage <paramref name="build"/> makes of the value, and nothing when there is none.</summary>
/// <remarks>
/// The alternative is <c>when' value.IsSome</c> above a <c>run</c> that reaches for <c>value.Value</c>,
/// 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 stage is built only for <c>Some</c>, and closes over the value itself. A <c>when' value.IsSome</c>
/// guards running, not building: a stage body that reads <c>value.Value</c> under it throws a
/// <c>NullReferenceException</c> while the stage is built, whatever the condition says.
/// <para>
/// The absent case is an empty list, not an inactive stage requiring a name.
/// </para>
Expand Down
Loading